Rule34 Favorites Search Gallery

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

// ==UserScript==
// @name         Rule34 Favorites Search Gallery
// @namespace    bruh3396
// @version      1.17.1
// @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==

// web_components.js

class HoldButton extends HTMLElement {
  /**
   * @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 (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 = 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() {
  }
}

customElements.define("hold-button", HoldButton);

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 = 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 = clamp(incrementedValue, 0, 9999);
  }
}


// utilities.js

const 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>`;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  setTimeout(() => {
    fetch((url))
      .then((response) => {
        if (response.status === 503) {
          requestPageInformation(url, onSuccess, delay + delayIncrement);
        }
        return response.text();
      })
      .then((html) => {
        onSuccess(html);
      });
  }, delay);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * @param {String} optionId
 * @param {String} optionText
 * @param {String} optionTitle
 * @param {Boolean} optionIsChecked
 * @param {Function} onOptionChanged
 * @param {Boolean} optionIsVisible
 * @param {String} optionHint
 * @returns {HTMLElement | null}
 */
function createFavoritesOption(optionId, optionText, optionTitle, optionIsChecked, onOptionChanged, optionIsVisible, optionHint = "") {
  const id = 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}
 */
function onSearchPage() {
  if (!FLAGS.onSearchPage.set) {
    FLAGS.onSearchPage.value = location.href.includes("page=post&s=list");
    FLAGS.onSearchPage.set = true;
  }
  return FLAGS.onSearchPage.value;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

function insertCommonStyleHTML() {
  insertStyleHTML(utilitiesHTML, "utilities-common-styles");
  toggleThumbHoverOutlines(false);
  setTimeout(() => {
    if (onSearchPage()) {
      removeInlineImgStyles();
    }
    configureVideoOutlines();
  }, 100);
}

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

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

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

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

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

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

          img {
            outline: none !important;
          }

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

function configureVideoOutlines() {
  const size = onMobileDevice() ? 2 : 3;
  const selector = onFavoritesPage() ? "&:has(img.video)" : ">img.video";

  insertStyleHTML(`
    .thumb-node, .thumb {

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

        &:has(img.gif) {
          outline: 2px solid hotpink;
        }
      }
    }
    `, "video-border");
}

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

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

        insertStyleHTML(`
          input[type=number] {
            background-color: #303030;
            color: white;
          }
          `, "dark-theme-number-input");
        insertStyleHTML(`
            #favorites-pagination-container {
              >button {
                border: 1px solid white !important;
                color: white !important;
              }
            }
          `, "pagination-style");
        insertStyleHTML(`
            .number {
              background-color: #303030;

              >hold-button,
              button {
                color: white;
            }`);
      }
    }
  }, 10);
}

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

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

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

function initializeUtilities() {
  if (onPostPage()) {
    return;
  }
  const enableOnSearchPages = getPreference("enableOnSearchPages", false) && getPerformanceProfile() === 0;

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

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

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

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

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

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

}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * @param {String} tags
 * @returns {String}
 */
function getContentType(tags) {
  tags += " ";
  const hasVideoTag = (/(?:^|\s)video(?:$|\s)/).test(tags);
  const hasAnimatedTag = (/(?:^|\s)animated(?:$|\s)/).test(tags);
  const isAnimated = hasAnimatedTag || hasVideoTag;
  const isAGif = hasAnimatedTag && !hasVideoTag;
  return isAGif ? "gif" : isAnimated ? "video" : "image";
}

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

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

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

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

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

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

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

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

/**
 * @param {String} tagName
 * @returns {Promise.<Boolean>}
 */
function isOfficialTag(tagName) {
  const tagPageURL = `https://rule34.xxx/index.php?page=tags&s=list&tags=${tagName}`;
  return fetch(tagPageURL)
    .then((response) => {
      if (response.ok) {
        return response.text();
      }
      throw new Error(response.statusText);
    })
    .then((html) => {
      const dom = new DOMParser().parseFromString(html, "text/html");
      const columnOfFirstRow = dom.getElementsByClassName("highlightable")[0].getElementsByTagName("td");
      return columnOfFirstRow.length === 3;
    })
    .catch((error) => {
      console.error(error);
      return false;
    });
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/**
 * @param {Set} a
 * @param {Set} b
 * @returns {Set}
 */
function 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}
 */
function difference(a, b) {
  const c = new Set(a);

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

function removeUnusedScripts() {
  if (!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>}
 */
function convertToTagSet(tagString) {
  tagString = removeExtraWhiteSpace(tagString);

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

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

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

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

function setupCustomWebComponents() {
  setupCustomNumberWebComponents();
}

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

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

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

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

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

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

/**
 * @returns {String[]}
 */
function 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}[]}
 */
function addCustomTagsToAutocompleteList(officialTags, searchQuery) {
  const customTags = Array.from(CUSTOM_TAGS);
  const officialTagValues = new Set(officialTags.map(officialTag => officialTag.value));
  const mergedTags = officialTags;

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

/**
 * @param {String} searchTag
 * @param {String} savedSearch
 * @returns {Boolean}
 */
function savedSearchMatchesSearchTag(searchTag, savedSearch) {
  const sanitizedSavedSearch = 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}
 */
function removeStartingHyphen(tag) {
  return tag.replace(/^-/, "");
}

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

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

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

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

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

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

/**
 * @param {Number} timestamp
 * @returns {String}
 */
function 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}
 */
function getSortingMethod() {
  const sortingMethodSelect = document.getElementById("sorting-method");
  return sortingMethodSelect === null ? "default" : sortingMethodSelect.value;
}

initializeUtilities();


// metadata.js

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

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

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

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

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

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

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

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

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

  /**
   * @type {Number}
   */
  get fetchDelay() {
    return this.postIsDeleted ? FavoriteMetadata.fetchDelay.deleted : FavoriteMetadata.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 {Number} rating
   */
  presetRating(rating) {
    this.rating = rating;
  }

  /**
   * @param {Number} score
   */
  presetScore(score) {
    this.score = score;
  }

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

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

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

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

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

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

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

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

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

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

        FavoriteMetadata.postStatisticsRegex.lastIndex = 0;

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

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

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

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

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

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

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

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

      default:
        break;
    }
    return result;
  }

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


// thumb_node.js

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

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

  /**
   * @param {HTMLElement} thumb
   * @returns {Number}
   */
  static extractScoreFromThumb(thumb) {
    // try {
    //   const score = (/'score':(\d+)/).exec(thumb.nextSibling.textContent)[1];
    //   return parseInt(score);
    // } catch {
    //   return 0;
    // }
    return 0;
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {String}
   */
  static getIdFromThumb(thumb) {
    return thumb.querySelector("a").id.replace(/\D/, "");
  }

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

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

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

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

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

      if (noScriptElement !== null) {
        thumb.children[0].insertAdjacentElement("afterbegin", noScriptElement.children[0]);
      }
    }
    // const image = thumb.children[0].children[0];
    const image = thumb.querySelector("img");

    this.id = InactiveThumbNode.getIdFromThumb(thumb);
    this.src = image.src;
    this.tags = this.preprocessTags(image);
    // this.rating = InactiveThumbNode.extractRatingFromThumb(thumb);
    // this.score = InactiveThumbNode.extractScoreFromThumb(thumb);
  }

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

  instantiateMetadata() {
    if (this.fromRecord) {
      return new FavoriteMetadata(this.id, this.metadata);
    }
    const favoritesMetadata = new FavoriteMetadata(this.id);
    // favoritesMetadata.presetRating(this.rating);
    // favoritesMetadata.presetScore(this.score);
    return favoritesMetadata;
  }

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

class ThumbNode {
  /**
   * @type {Map.<String, ThumbNode>}
   */
  static allThumbNodes = 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 = getPreference("sortingMethod", "default");
  static settings = {
    deferHTMLElementCreation: true
  };

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

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

    ThumbNode.template = new DOMParser().parseFromString("<div class=\"thumb-node\"></div>", "text/html").createElement("div");
    ThumbNode.template.className = "thumb-node";
    ThumbNode.template.innerHTML = `
        <div>
          <img loading="lazy">
          ${buttonHTML}
          ${canvasHTML}
        </div>
    `;
  }

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

      if (thumbNode !== undefined) {
        thumbNode.swapAddOrRemoveButton();
      }
    });
    window.addEventListener("sortingParametersChanged", () => {
      ThumbNode.currentSortingMethod = getSortingMethod();
      const thumbNodes = Array.from(getAllThumbs()).map(thumb => ThumbNode.allThumbNodes.get(thumb.id));

      for (const thumbNode of thumbNodes) {
        thumbNode.createStatisticHint();
      }
    });
  }

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

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

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

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

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

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

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

  /**
   * @type {String}
   */
  id;
  /**
   * @type {HTMLDivElement}
   */
  root;
  /**
   * @type {HTMLElement}
   */
  container;
  /**
   * @type {HTMLImageElement}
   */
  image;
  /**
   * @type {HTMLButtonElement}
   */
  addOrRemoveButton;
  /**
   * @type {HTMLDivElement}
   */
  statisticHint;
  /**
   * @type {InactiveThumbNode}
   */
  inactiveThumbNode;
  /**
   * @type {Boolean}
   */
  essentialAttributesPopulated;
  /**
   * @type {Boolean}
   */
  htmlElementCreated;
  /**
   * @type {Set.<String>}
   */
  tagSet;
  /**
   * @type {Set.<String>}
   */
  additionalTags;
  /**
   * @type {Number}
   */
  originalTagsLength;
  /**
   * @type {Boolean}
   */
  matchedByMostRecentSearch;
  /**
   * @type {FavoriteMetadata}
   */
  metadata;

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

  /**
   * @type {String}
   */
  get compressedThumbSource() {
    const source = this.inactiveThumbNode === null ? this.image.src : this.inactiveThumbNode.src;
    return source.match(ThumbNode.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 convertToTagString(this.originalTagSet);
  }

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

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

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

  /**
   * @param {InactiveThumbNode} inactiveThumbNode
   */
  initialize(inactiveThumbNode) {
    if (ThumbNode.settings.deferHTMLElementCreation) {
      this.inactiveThumbNode = inactiveThumbNode;
      this.populateEssentialAttributes(inactiveThumbNode);
    } else {
      this.createHTMLElement(inactiveThumbNode);
    }
  }

  /**
   * @param {InactiveThumbNode} inactiveThumbNode
   */
  populateEssentialAttributes(inactiveThumbNode) {
    if (this.essentialAttributesPopulated) {
      return;
    }
    this.essentialAttributesPopulated = true;
    this.populateNonHTMLAttributes(inactiveThumbNode);
    this.initializeAdditionalTags();
    this.deleteConsumedProperties(inactiveThumbNode);
  }

  /**
   * @param {InactiveThumbNode} inactiveThumbNode
   */
  createHTMLElement(inactiveThumbNode) {
    if (this.htmlElementCreated) {
      return;
    }
    this.htmlElementCreated = true;
    this.instantiateTemplate();
    this.populateEssentialAttributes(inactiveThumbNode);
    this.populateHTMLAttributes(inactiveThumbNode);
    this.setupAddOrRemoveButton();
    this.setupClickLink();
    this.deleteInactiveThumbNode();
  }

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

  instantiateTemplate() {
    this.root = ThumbNode.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];
  }

  setupAddOrRemoveButton() {
    if (userIsOnTheirOwnFavoritesPage()) {
      this.addOrRemoveButton.onclick = this.removeFavoriteButtonOnClick.bind(this);
    } else {
      this.addOrRemoveButton.onclick = this.addFavoriteButtonOnClick.bind(this);
    }
  }

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

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

    this.swapAddOrRemoveButton();
  }

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

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

  /**
   * @param {InactiveThumbNode} inactiveThumbNode
   */
  populateNonHTMLAttributes(inactiveThumbNode) {
    this.id = inactiveThumbNode.id;
    this.tagSet = convertToTagSet(`${inactiveThumbNode.id} ${inactiveThumbNode.tags}`);
    this.metadata = inactiveThumbNode.instantiateMetadata();
  }

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

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

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

  /**
   * @param {InactiveThumbNode} inactiveThumbNode
   */
  deleteConsumedProperties(inactiveThumbNode) {
    inactiveThumbNode.metadata = null;
    inactiveThumbNode.tags = null;
  }

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

  deleteInactiveThumbNode() {
    if (this.inactiveThumbNode !== null) {
      this.inactiveThumbNode.clear();
      this.inactiveThumbNode = null;
    }
  }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      default:
        return this.id;
    }
  }

  async createStatisticHint() {
    // await sleep(200);
    // let hint = this.getStatisticHint();

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


// match.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /**
   * @param {String} searchTag
   * @param {Boolean} inOrGroup
   */
  constructor(searchTag) {
    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 {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matches(thumbNode) {
    const metadata = FavoriteMetadata.allMetadata.get(thumbNode.id);

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

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

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)) {
        continue;
      }
      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} = 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 {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  matches(thumbNode) {
    if (this.isEmpty) {
      return true;
    }

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

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

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

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


// loader.js

/* eslint-disable no-bitwise */
class FavoritesPageRequest {
  /**
   * @type {Number}
   */
  pageNumber;
  /**
   * @type {Number}
   */
  retryCount;
  /**
   * @type {ThumbNode[]}
   */
  fetchedThumbNodes;
  /**
   * @type {ThumbNode[]}
   */
  matchedThumbNodes;

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

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

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

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

class FavoritesLoader {
  static loadingStates = {
    initial: 0,
    retrievingDatabaseStatus: 1,
    fetchingFavorites: 2,
    loadingFavoritesFromDatabase: 3,
    allFavoritesLoaded: 4
  };
  static currentLoadingState = FavoritesLoader.loadingStates.initial;
  static databaseName = "Favorites";
  static objectStoreName = `user${getFavoritesPageId()}`;
  static webWorkers = {
    database:
      `
/* eslint-disable prefer-template */
/**
 * @param {Number} milliseconds
 * @returns {Promise}
 */
function sleep(milliseconds) {
  return new Promise(resolve => setTimeout(resolve, milliseconds));
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    default:
      break;
  }
};

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

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

  /**
   * @type {{highestInsertedPageNumber : Number, emptying: Boolean, insertionQueue: FavoritesPageRequest[]}}
   */
  fetchedThumbNodes;
  /**
   * @type {ThumbNode[]}
   */
  allThumbNodes;
  /**
   * @type {ThumbNode[]}
   */
  latestSearchResults;
  /**
   * @type {Number}
   */
  finalPageNumber;
  /**
   * @type {HTMLLabelElement}
   */
  matchCountLabel;
  /**
   * @type {Number}
   */
  matchingFavoritesCount;
  /**
   * @type {Number}
   */
  resultsPerPage;
  /**
   * @type {FavoritesPageRequest[]}
   */
  failedFetchRequests;
  /**
   * @type {Number}
   */
  expectedFavoritesCount;
  /**
   * @type {Boolean}
   */
  expectedFavoritesCountIsKnown;
  /**
   * @type {String}
   */
  searchQuery;
  /**
   * @type {String}
   */
  previousSearchQuery;
  /**
   * @type {Worker}
   */
  databaseWorker;
  /**
   * @type {Boolean}
   */
  searchResultsAreShuffled;
  /**
  /**
   * @type {Boolean}
   */
  searchResultsAreInverted;
  /**
   * @type {Boolean}
   */
  searchResultsWereShuffled;
  /**
  /**
   * @type {Boolean}
   */
  searchResultsWereInverted;
  /**
   * @type {Number}
   */
  currentFavoritesPageNumber;
  /**
   * @type {HTMLElement}
   */
  paginationContainer;
  /**
   * @type {HTMLLabelElement}
   */
  paginationLabel;
  /**
   * @type {Boolean}
   */
  foundEmptyFavoritesPage;
  /**
   * @type {ThumbNode[]}
   */
  searchResultsWhileFetching;
  /**
   * @type {Number}
   */
  recentlyChangedResultsPerPage;
  /**
   * @type {Number}
   */
  maxPageNumberButtonCount;
  /**
   * @type {Boolean}
   */
  newPageNeedsToBeCreated;
  /**
   * @type {Boolean}
   */
  tagsWereModified;
  /**
   * @type {Boolean}
   */
  excludeBlacklistWasClicked;
  /**
   * @type {Boolean}
   */
  sortingParametersWereChanged;
  /**
   * @type {Boolean}
   */
  allowedRatingsWereChanged;
  /**
   * @type {Number}
   */
  allowedRatings;
  /**
   * @type {String[]}
   */
  idsRequiringMetadataDatabaseUpdate;
  /**
   * @type {Number}
   */
  newMetadataReceivedTimeout;

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

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

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

  constructor() {
    if (FavoritesLoader.disabled) {
      return;
    }
    this.initializeFields();
    this.addEventListeners();
    this.initialize();
  }

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

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

  createDatabaseMessageHandler() {
    this.databaseWorker.onmessage = (message) => {
      switch (message.data.response) {
        case "finishedLoading":
          this.paginateSearchResults(this.reconstructContent(message.data.favorites));
          this.onAllFavoritesLoaded();
          setTimeout(() => {
            this.findNewFavoritesOnReload(this.getAllFavoriteIds(), 0, []);
          }, 100);
          break;

        case "finishedStoring":
          break;

        default:
          break;
      }
    };
  }

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

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

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

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

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

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

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

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

  showSearchResults() {
    switch (FavoritesLoader.currentLoadingState) {
      case FavoritesLoader.loadingStates.initial:
        this.getAllFavorites();
        break;

      case FavoritesLoader.loadingStates.retrievingDatabaseStatus:
        break;

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

      case FavoritesLoader.loadingStates.loadingFavoritesFromDatabase:
        break;

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

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

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

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

  async getAllFavorites() {
    FavoritesLoader.currentLoadingState = FavoritesLoader.loadingStates.retrievingDatabaseStatus;
    const databaseStatus = await this.getDatabaseStatus();

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  async fetchAllFavorites() {
    let currentPageNumber = 0;

    while (FavoritesLoader.currentLoadingState === FavoritesLoader.loadingStates.fetchingFavorites) {
      if (this.failedFetchRequests.length > 0) {
        await this.refetchFailedFavoritesPage();
        continue;
      }

      if (currentPageNumber * 50 <= this.finalPageNumber && !this.foundEmptyFavoritesPage) {
        await this.fetchNewFavoritesPage(currentPageNumber);
        currentPageNumber += 1;
        continue;
      }

      if (this.isFinishedFetching(currentPageNumber)) {
        this.onAllFavoritesLoaded();
        this.storeFavorites();
        return;
      }
      await sleep(1000);
    }
  }

  async refetchFailedFavoritesPage() {
    const failedRequest = this.failedFetchRequests.shift();

    this.fetchFavorites(failedRequest);
    await sleep(failedRequest.retryDelay);
  }

  /**
   * @param {Number} pageNumber
   */
  async fetchNewFavoritesPage(pageNumber) {
    this.fetchFavorites(new FavoritesPageRequest(pageNumber));
    await sleep(210);
  }

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

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

  /**
   * @param {FavoritesPageRequest} request
   */
  fetchFavorites(request) {
    fetch(request.url)
      .then((response) => {
        if (response.ok) {
          return response.text();
        }
        throw new Error(`${response.status}: Failed to fetch, ${request.url}`);
      })
      .then((html) => {
        request.fetchedThumbNodes = this.extractThumbNodesFromFavoritesPage(html);
        request.matchedThumbNodes = this.getSearchResults(request.fetchedThumbNodes);

        this.addFetchedThumbNodesToInsertionQueue(request);
        this.foundEmptyFavoritesPage = request.fetchedThumbNodes.length === 0;
      })
      .catch((error) => {
        console.error(error);
        request.onFail();
        this.failedFetchRequests.push(request);
      });
  }

  /**
   * @param {FavoritesPageRequest} request
   */
  addFetchedThumbNodesToInsertionQueue(request) {
    this.fetchedThumbNodes.insertionQueue.push(request);
    this.fetchedThumbNodes.insertionQueue.sort((request1, request2) => request1.pageNumber - request2.pageNumber);
    this.emptyInsertionQueue();
  }

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

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

      if (this.previousPageNumberIsPresent(request.pageNumber)) {
        this.processFetchedThumbNodes(request);
        this.fetchedThumbNodes.insertionQueue.shift();
        this.fetchedThumbNodes.highestInsertedPageNumber += 1;
      } else {
        break;
      }
    }
    this.fetchedThumbNodes.emptying = false;
  }

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

  /**
   * @param {FavoritesPageRequest} request
   */
  processFetchedThumbNodes(request) {
    this.searchResultsWhileFetching = this.searchResultsWhileFetching.concat(request.matchedThumbNodes);
    const searchResultsWhileFetchingWithAllowedRatings = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);

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

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

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

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

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

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

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

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

  onAllFavoritesLoaded() {
    if (FavoritesLoader.currentLoadingState === FavoritesLoader.loadingStates.fetchingFavorites) {
      this.latestSearchResults = this.getResultsWithAllowedRatings(this.searchResultsWhileFetching);
      dispatchEvent(new CustomEvent("newSearchResults", {
        detail: this.latestSearchResults
      }));
    }
    dispatchEvent(new Event("readyToSearch"));
    FavoritesLoader.currentLoadingState = FavoritesLoader.loadingStates.allFavoritesLoaded;
    this.toggleLoadingUI(false);
    dispatchEvent(new Event("favoritesLoaded"));
  }

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

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

  /**
   * @param {[{id: String, tags: String, src: String}]} databaseRecords
   * @returns {ThumbNode[]}}
   */
  reconstructContent(databaseRecords) {
    if (databaseRecords === null) {
      return null;
    }
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const searchResults = [];

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

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

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

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

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

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

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

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

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

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

  deletePersistentData() {
    const message = `
Are you sure you want to reset?
This will delete all cached favorites, and preferences.
Tag modifications and saved searches will be preserved.
    `;

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

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

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

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

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

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

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

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

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

  /**
   * @param {ThumbNode[]} newThumbNodes
   */
  async insertNewFavorites(newThumbNodes) {
    const content = document.getElementById("content");
    const searchCommand = new SearchCommand(this.finalSearchQuery);
    const insertedThumbNodes = [];
    const metadataPopulateWaitTime = 1000;

    newThumbNodes.reverse();

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

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

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

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

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

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

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

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

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

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

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

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

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

  /**
   * @param {ThumbNode[]} searchResults
   */
  paginateSearchResults(searchResults) {
    searchResults = this.sortThumbNodes(searchResults);
    searchResults = this.getResultsWithAllowedRatings(searchResults);
    this.latestSearchResults = searchResults;
    this.updateMatchCount(searchResults.length);
    this.insertPaginationContainer();
    this.changeResultsPage(1, searchResults);
    dispatchEvent(new CustomEvent("newSearchResults", {
      detail: searchResults
    }));
  }

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

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

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

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

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

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

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

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

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

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

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

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

    previousPage.textContent = "<";
    firstPage.textContent = "<<";
    nextPage.textContent = ">";
    finalPage.textContent = ">>";

    previousPage.id = "previous-page";
    firstPage.id = "first-page";
    nextPage.id = "next-page";
    finalPage.id = "final-page";

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

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

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

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

    input.onkeydown = (event) => {
      if (event.key === "Enter") {
        button.click();
      }
    };

    this.paginationContainer.appendChild(container);
    this.updatePageButtonEventListeners(searchResults);
  }

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

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

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

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

    };

    finalPageButton.onclick = () => {
      this.changeResultsPage(pageCount, searchResults);
    };
  }

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

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

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

  resetFlagsThatImplyDifferentSearchResults() {
    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.previousSearchQuery = this.searchQuery;
  }

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

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

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

  /**
   * @param {ThumbNode[]} thumbNodes
   */
  reAddThumbNodeEventListeners(thumbNodes) {
    for (const thumbNode of thumbNodes) {
      thumbNode.setupAddOrRemoveButton();
    }
  }

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

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

  /**
   * @param {Number} pageNumber
   * @returns {Boolean}
   */
  aNewSearchWillProduceDifferentResults(pageNumber) {
    return this.currentFavoritesPageNumber !== pageNumber ||
      this.searchQuery !== this.previousSearchQuery ||
      FavoritesLoader.currentLoadingState !== FavoritesLoader.loadingStates.allFavoritesLoaded ||
      this.searchResultsAreShuffled ||
      this.searchResultsAreInverted ||
      this.searchResultsWereShuffled ||
      this.searchResultsWereInverted ||
      this.recentlyChangedResultsPerPage ||
      this.tagsWereModified ||
      this.excludeBlacklistWasClicked ||
      this.sortingParametersWereChanged ||
      this.allowedRatingsWereChanged;
  }

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

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

    if (searchResultsLength <= this.resultsPerPage) {
      this.paginationLabel.textContent = "";
      return;
    }
    this.paginationLabel.textContent = `${start} - ${end}`;
  }

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

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

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

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

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

  }

  /**
   * @param {ThumbNode[]} thumbNodes
   * @returns {ThumbNode[]}
   */
  sortThumbNodes(thumbNodes) {
    if (!FavoritesLoader.loadingStates.allFavoritesLoaded) {
      alert("Wait for all favorites to load before changing sort method");
      return thumbNodes;
    }

    if (this.searchResultsAreShuffled) {
      return thumbNodes;
    }
    const sortedThumbNodes = thumbNodes.slice();
    const sortingMethod = getSortingMethod();

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

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

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

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

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

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

          default:
            return 0;
        }
      });
    }

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

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

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

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

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

    this.paginateSearchResults(matchedThumbNodes);
  }

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

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

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

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

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

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

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

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

  /**
   * @param {Number} id
   */
  findFavorite(id) {
    const searchResults = this.latestSearchResults;
    const searchResultIds = searchResults.map(thumbNode => thumbNode.id);
    const index = searchResultIds.indexOf(id);

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

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

  /**
   *
   * @param {String} direction
   */
  changeResultsPageInGallery(direction) {
    const searchResults = this.latestSearchResults;
    const pageCount = this.getPageCount(searchResults.length);
    const onLastPage = this.currentFavoritesPageNumber === pageCount;
    const onFirstPage = this.currentFavoritesPageNumber === 1;
    const onlyOnePage = onFirstPage && onLastPage;

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

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

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

    this.changeResultsPage(newPageNumber, searchResults);
  }
}

const favoritesLoader = new FavoritesLoader();


// ui.js

const uiHTML = `<div id="favorites-top-bar" class="light-green-gradient not-highlightable">
  <style>
    #favorites-top-bar {
      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-top-bar-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(50%);

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

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

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


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

        &:has(.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;
        }
      }
    }

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

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

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

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

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

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

    #whats-new-container {
      z-index: 10;
      top: 20px;
      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;

        >li {
          /* list-style: none; */
        }
      }

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

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

    #favorites-fetch-progress-label {
      color: #3498db;
    }

    #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-top-bar-panels" style="display: flex;">
    <div id="left-favorites-panel">
      <h2 style="display: inline;">Search Favorites</h2>
      <span id="favorites-load-status" style="margin-left: 5px;">
        <label id="match-count-label"></label>
        <label id="pagination-label" style="margin-left: 10px;"></label>
        <label id="favorites-fetch-progress-label" style="padding-left: 20px; color: #3498db;"></label>
      </span>
      <div id="left-favorites-panel-top-row">
        <button title="Search favorites\nctrl+click/right-click: Search all of rule34 in a new tab"
          id="search-button">Search</button>
        <button title="Randomize order of search results" id="shuffle-button">Shuffle</button>
        <button title="Show results not matched by search" id="invert-button">Invert</button>
        <button title="Empty the search box" id="clear-button">Clear</button>
        <button title="Remove cached favorites and preferences" id="reset-button">Reset</button>
        <span id="favorites-pagination-placeholder"></span>
        <span id="help-links-container">
          <a href="https://github.com/bruh3396/favorites-search-gallery#controls" target="_blank">Help</a>
          |

          <a href="https://sleazyfork.org/en/scripts/504184-rule34-favorites-search-gallery/feedback"
            target="_blank">Feedback</a>
          |

          <a href="https://github.com/bruh3396/favorites-search-gallery/issues" target="_blank">Report
            Issue</a>
          |

          <a id="whats-new-link" href="" class="hidden light-green-gradient">What's new?

            <div id="whats-new-container" class="light-green-gradient">
              <h4>1.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 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>
                  <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="Sort 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\nLower 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>`;/* eslint-disable no-bitwise */

if (onFavoritesPage()) {
  document.getElementById("content").insertAdjacentHTML("beforebegin", uiHTML);
}
const MAX_SEARCH_HISTORY_LENGTH = 100;
const FAVORITE_PREFERENCES = {
  showAddOrRemoveButtons: 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"
};
const FAVORITE_LOCAL_STORAGE = {
  searchHistory: "favoritesSearchHistory"
};
const FAVORITE_BUTTONS = {
  search: document.getElementById("search-button"),
  shuffle: document.getElementById("shuffle-button"),
  clear: document.getElementById("clear-button"),
  invert: document.getElementById("invert-button"),
  reset: document.getElementById("reset-button"),
  findFavorite: document.getElementById("find-favorite-button")
};
const FAVORITE_CHECKBOXES = {
  showOptions: document.getElementById("options-checkbox"),
  showAddOrRemoveButtons: 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")
};
const FAVORITE_INPUTS = {
  searchBox: document.getElementById("favorites-search-box"),
  findFavorite: document.getElementById("find-favorite-input"),
  columnCount: document.getElementById("column-resize-input"),
  performanceProfile: document.getElementById("performance-profile"),
  resultsPerPage: document.getElementById("results-per-page-input"),
  sortingMethod: document.getElementById("sorting-method"),
  allowedRatings: document.getElementById("allowed-ratings")
};
const columnWheelResizeCaptionCooldown = new Cooldown(500, true);

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

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

function loadFavoritesPagePreferences() {
  const userIsLoggedIn = getUserId() !== null;
  const showAddOrRemoveButtonsDefault = !userIsOnTheirOwnFavoritesPage() && userIsLoggedIn;
  const addOrRemoveFavoriteButtonsAreVisible = getPreference(FAVORITE_PREFERENCES.showAddOrRemoveButtons, showAddOrRemoveButtonsDefault);

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

  const showOptions = getPreference(FAVORITE_PREFERENCES.showOptions, false);

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

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

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

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

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

  const performanceProfile = getPerformanceProfile();

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

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

  changeResultsPerPage(resultsPerPage);

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

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

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

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

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

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

  const showStatisticHints = getPreference(FAVORITE_PREFERENCES.showStatisticHints, false);

  FAVORITE_CHECKBOXES.showStatisticHints.checked = showStatisticHints;
  toggleStatisticHints(showStatisticHints);
}

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

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

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

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

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

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

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

      case "ArrowUp":

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

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

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

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

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

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

      case "u":
        FAVORITE_CHECKBOXES.showUI.click();
        break;

      case "o":
        FAVORITE_CHECKBOXES.showOptions.click();
        break;

      case "h":
          FAVORITE_CHECKBOXES.showHotkeyHints.click();
        break;

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

      default:
        break;
    }
  }, {
    passive: true
  });
  window.addEventListener("load", () => {
    FAVORITE_INPUTS.searchBox.focus();
  }, {
    once: true
  });
  FAVORITE_CHECKBOXES.showStatisticHints.onchange = () => {
    toggleStatisticHints(FAVORITE_CHECKBOXES.showStatisticHints.checked);
    setPreference(FAVORITE_PREFERENCES.showStatisticHints, FAVORITE_CHECKBOXES.showStatisticHints.checked);
  };
  window.addEventListener("searchForTag", (event) => {
    FAVORITE_INPUTS.searchBox.value = event.detail;
    FAVORITE_BUTTONS.search.click();
  });
}

function configureAddOrRemoveButtonOptionVisibility() {
  FAVORITE_CHECKBOXES.showAddOrRemoveButtons.parentElement.parentElement.style.display = "block";
}

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

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

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

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

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

function toggleAddOrRemoveButtons() {
  const value = FAVORITE_CHECKBOXES.showAddOrRemoveButtons.checked;

  toggleAddOrRemoveButtonVisibility(value);
  toggleThumbHoverOutlines(value);
  forceHideCaptions(value);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  container.id = "container";

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

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

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

function configureDesktopUI() {
  if (onMobileDevice()) {
    return;
  }
  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;
    }
  `);
}

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

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

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

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

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

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

function changeAllowedRatings() {
  let allowedRatings = 0;

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

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

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

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

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

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

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

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

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

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

async function addHintsOption() {
  toggleOptionHints(false);

  await sleep(50);

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

  FAVORITE_CHECKBOXES.showHotkeyHints.checked = optionHintsEnabled;
  FAVORITE_CHECKBOXES.showHotkeyHints.onchange = () => {
    toggleOptionHints(FAVORITE_CHECKBOXES.showHotkeyHints.checked);
    setPreference(FAVORITE_PREFERENCES.showHotkeyHints, FAVORITE_CHECKBOXES.showHotkeyHints.checked);
  };
  toggleOptionHints(optionHintsEnabled);
}

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

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

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


// autoplay.js

const autoplayHTML = `<div id="autoplay-menu-container">
  <style>
    #autoplay-menu-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>`;

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 preferences = {
    active: "autoplayActive",
    paused: "autoplayPaused",
    imageDuration: "autoplayImageDuration",
    minimumVideoDuration: "autoplayMinimumVideoDuration",
    direction: "autoplayForward"
  };
  static menuIconImageURLs = {
    play: createObjectURLFromSvg(ICONS.play),
    pause: createObjectURLFromSvg(ICONS.pause),
    changeDirection: createObjectURLFromSvg(ICONS.changeDirection),
    changeDirectionAlt: createObjectURLFromSvg(ICONS.changeDirectionAlt),
    tune: createObjectURLFromSvg(ICONS.tune)
  };
  static settings = {
    imageViewDuration: getPreference(Autoplay.preferences.imageDuration, 3000),
    minimumVideoDuration: getPreference(Autoplay.preferences.minimumVideoDuration, 5000),
    menuVisibilityDuration: 500,
    moveForward: getPreference(Autoplay.preferences.direction, true),

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

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

  /**
   * @type {Boolean}
   */
  static get disabled() {
    return 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 = getPreference(Autoplay.preferences.active, false);
    this.paused = 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() {
    document.body.insertAdjacentHTML("afterbegin", autoplayHTML);
    this.ui.container = document.getElementById("autoplay-menu-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() {
    createFavoritesOption(
      "autoplay",
      "Autoplay",
      "Enable autoplay in gallery.",
      this.active,
      (event) => {
        this.toggle(event.target.checked);
      },
      true
    );
  }

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

  insertVideoProgressHTML() {
    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);
      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 && isImage(this.currentThumb)) {
        this.startViewTimer(this.currentThumb);
      }
    };
    this.ui.settingsMenu.minimumVideoDurationInput.onchange = () => {
      this.setMinimumVideoViewDuration();

      if (this.currentThumb !== null && !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) {
    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(clamp(durationInSeconds * 1000, 1000, 60000));

    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(clamp(durationInSeconds * 1000, 0, 60000));

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


// gallery.js

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

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

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

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

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

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

  a.hide {
    cursor: default;
  }

  option {
    font-size: 15px;
  }

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

  .thumb-node,
  .thumb {

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

  #original-content-background {
    position: fixed;
    top: 0px;
    left: 0px;
    width: 100%;
    height: 100%;
    background: black;
    z-index: 999;
    display: none;
    pointer-events: none;
  }

</style>
`;/* eslint-disable no-useless-escape */

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

    &.loaded {

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

      .image {
        opacity: 1;
      }
    }

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

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

  }

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

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

    100% {
      outline-color: turquoise;
    }
  }

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

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

  `;

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

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

  /**
   * @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();
    this.hasStarted = false;
  }
}

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 truncatedRequest = [];
    let currentMegabyteSize = 0;

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

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

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

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

    this.removeCompletedRenderRequests(batchRenderRequest);

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

    for (const request of batchRenderRequest.renderRequests) {
      if (this.isApartOfOutdatedBatchRequest(batchRequestId) || request.hasStarted) {
        continue;
      }
      this.renderImage(request, batchRequestId);
      await sleep(request.fetchDelay);
    }
  }

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

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

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

    if (this.isApartOfOutdatedBatchRequest(batchRequestId)) {
      return;
    }
    const imageBitmap = await createImageBitmap(blob);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /**
   * @param {BatchRenderRequest} newBatchRenderRequest
   */
  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} newBatchRenderRequest
   */
  markRenderRequestsThatHaveAlreadyStarted(newBatchRenderRequest) {
    if (!this.hasBatchRenderRequest) {
      return;
    }
    const previousRequestIds = this.batchRenderRequest.renderRequestIds;

    newBatchRenderRequest.renderRequests.forEach((request) => {
      request.hasStarted = previousRequestIds.has(request.id);
    });
  }

  /**
   * @param {BatchRenderRequest} batchRenderRequest
   */
  removeCompletedRenderRequests(batchRenderRequest) {
    batchRenderRequest.renderRequests = batchRenderRequest.renderRequests
      .filter(request => !this.renderIsFinished(request.id));
  }

  upscaleAllRenderedThumbs() {
    for (const render of this.renders.values()) {
      this.thumbUpscaler.upscale(render.request, render.imageBitmap);
    }
  }

  onmessage(message) {
    let batchRenderRequest;

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

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

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

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

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

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

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

      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: onMobileDevice() ? "7680x4320" : "3840x2160",
    favorites: "7680x4320"
  };
  static htmlAttributes = {
    thumbIndex: "index"
  };
  static extensionDecodings = {
    0: "jpg",
    1: "png",
    2: "jpeg",
    3: "gif"
  };
  static extensionEncodings = {
    "jpg": 0,
    "png": 1,
    "jpeg": 2,
    "gif": 3
  };
  static swipeControls = {
    threshold: 60,
    touchStart: {
      x: 0,
      y: 0
    },
    touchEnd: {
      x: 0,
      y: 0
    },
    get deltaX() {
      return this.touchStart.x - this.touchEnd.x;
    },
    get deltaY() {
      return this.touchStart.y - this.touchEnd.y;
    },
    get right() {
      return this.deltaX < -this.threshold;
    },
    get left() {
      return this.deltaX > this.threshold;
    },
    get up() {
      return this.deltaY > this.threshold;
    },
    get down() {
      return this.deltaY < -this.threshold;
    },
    /**
     * @param {TouchEvent} touchEvent
     * @param {Boolean} atStart
     */
    set(touchEvent, atStart) {
      if (atStart) {
        this.touchStart.x = touchEvent.changedTouches[0].screenX;
        this.touchStart.y = touchEvent.changedTouches[0].screenY;
      } else {
        this.touchEnd.x = touchEvent.changedTouches[0].screenX;
        this.touchEnd.y = touchEvent.changedTouches[0].screenY;
      }
    }
  };
  static settings = {
    maxImagesToRenderInBackground: 50,
    maxImagesToRenderAround: onMobileDevice() ? 2 : 50,
    megabyteLimit: onMobileDevice() ? 0 : 375,
    minImagesToRender: onMobileDevice() ? 3 : 8,
    imageFetchDelay: 250,
    throttledImageFetchDelay: 400,
    imageFetchDelayWhenExtensionKnown: 25,
    upscaledThumbResolutionFraction: 4,
    upscaledAnimatedThumbResolutionFraction: 6,
    extensionsFoundBeforeSavingCount: 100,
    animatedThumbsToUpscaleRange: 20,
    animatedThumbsToUpscaleDiscrete: 20,
    traversalCooldownTime: 300,
    renderOnPageChangeCooldownTime: 2000,
    addFavoriteCooldownTime: 250,
    cursorVisibilityCooldownTime: 500,
    imageExtensionAssignmentCooldownTime: 1000,
    additionalVideoPlayerCount: onMobileDevice() ? 0 : 2,
    renderAroundAggressively: true,
    loopAtEndOfGalleryValue: false,
    get loopAtEndOfGallery() {
      if (!onFavoritesPage() || !Gallery.finishedLoading) {
        return true;
      }
      return this.loopAtEndOfGalleryValue;
    },
    debugEnabled: false,
    developerMode: 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 imageExtensionAssignmentCooldown = new Cooldown(Gallery.settings.imageExtensionAssignmentCooldownTime);
  static finishedLoading = onSearchPage();

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

  /**
   * @type {Autoplay}
   */
  autoplayController;
  /**
   * @type {HTMLDivElement}
   */
  originalContentContainer;
  /**
   * @type {HTMLCanvasElement}
   */
  mainCanvas;
  /**
   * @type {HTMLCanvasElement}
   */
  lowResolutionCanvas;
  /**
   * @type {CanvasRenderingContext2D}
   */
  lowResolutionContext;
  /**
   * @type {HTMLDivElement}
   */
  videoContainer;
  /**
   * @type {HTMLVideoElement[]}
   */
  videoPlayers;
  /**
   * @type {HTMLImageElement}
   */
  gifContainer;
  /**
   * @type {HTMLDivElement}
   */
  background;
  /**
   * @type {HTMLElement}
   */
  thumbUnderCursor;
  /**
   * @type {HTMLElement}
   */
  lastEnteredThumb;
  /**
   * @type {Worker}
   */
  imageRenderer;
  /**
   * @type {Set.<String>}
   */
  startedRenders;
  /**
   * @type {Set.<String>}
   */
  completedRenders;
  /**
   * @type {Map.<String, HTMLCanvasElement>}
   */
  transferredCanvases;
  /**
   * @type {Map.<String, {start: Number, end:Number}>}
   */
  videoClips;
  /**
   * @type {HTMLElement[]}
   */
  visibleThumbs;
  /**
   * @type {ThumbNode[]}
   */
  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.loadDiscoveredImageExtensions();
    this.prepareSearchPage();
    this.insertHTML();
    this.updateBackgroundOpacity(getPreference(Gallery.preferences.backgroundOpacity, 1));
    this.loadVideoClips();
    this.setMainCanvasOrientation();
  }

  initializeFields() {
    this.mainCanvas = document.createElement("canvas");
    this.lowResolutionCanvas = document.createElement("canvas");
    this.lowResolutionContext = this.lowResolutionCanvas.getContext("2d");
    this.thumbUnderCursor = null;
    this.lastEnteredThumb = null;
    this.startedRenders = new Set();
    this.completedRenders = new Set();
    this.transferredCanvases = new Map();
    this.videoClips = new Map();
    this.visibleThumbs = [];
    this.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 = getPreference(Gallery.preferences.showOnHover, true);
    this.enlargeOnClickOnMobile = getPreference(Gallery.preferences.enlargeOnClick, true);
  }

  initializeTimers() {
    Gallery.backgroundRenderingOnPageChangeCooldown.onDebounceEnd = () => {
      this.onPageChange();
    };
    Gallery.imageExtensionAssignmentCooldown.onCooldownEnd = () => {
      if (this.recentlyDiscoveredImageExtensionCount > 0) {
        this.storeAllImageExtensions();
      }
    };
  }

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

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

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

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

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

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

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

  addGalleryEventListeners() {
    window.addEventListener("load", () => {
      if (onSearchPage()) {
        this.initializeThumbsForHovering.bind(this)();
        this.enumerateVisibleThumbs();
      }
      this.hideCaptionsWhenShowingOriginalContent();
    }, {
      once: true,
      passive: true
    });
    document.addEventListener("mousedown", (event) => {
      const autoplayMenu = document.getElementById("autoplay-menu");

      if (autoplayMenu !== null && autoplayMenu.contains(event.target)) {
        return;
      }
      const clickedOnAnImage = event.target.tagName.toLowerCase() === "img";
      const clickedOnAThumb = clickedOnAnImage && getThumbFromImage(event.target).className.includes("thumb");
      const clickedOnACaptionTag = event.target.classList.contains("caption-tag");
      const thumb = clickedOnAThumb ? getThumbFromImage(event.target) : null;

      if (clickedOnAThumb) {
        this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
      }

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

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

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

        case CLICK_CODES.middle:
          event.preventDefault();

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

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

      if (this.inGallery) {
        if (event.ctrlKey) {
          return;
        }
        const delta = (event.wheelDelta ? event.wheelDelta : -event.deltaY);
        const direction = delta > 0 ? Gallery.directions.left : Gallery.directions.right;

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

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

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

        case Gallery.directions.d:

        case Gallery.directions.left:

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

        case "X":

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

        case " ":
          if (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;
      }

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

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

        case "M":

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

        case "B":

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

        case "n":
          this.toggleCursorVisibility(true);
          Gallery.cursorVisibilityCooldown.restart();
          break;

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

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

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

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

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

        this.renderImagesInTheBackground();

        if (thumb !== null && !Gallery.finishedLoading) {
          this.upscaleAnimatedThumbsAround(thumb);
        }
      }, 650);
    }, {
      once: true
    });
    window.addEventListener("favoritesLoaded", () => {
      Gallery.backgroundRenderingOnPageChangeCooldown.waitTime = 1000;
      Gallery.finishedLoading = true;
      this.initializeThumbsForHovering.bind(this)();
      this.enumerateVisibleThumbs();
      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.enumerateVisibleThumbs();

      if (this.changedPageWhileInGallery) {
        setTimeout(() => {
          this.imageRenderer.postMessage({
            action: "upscaleAllRenderedThumbs"
          });
        }, 100);
      } else {
        this.clearMainCanvas();
        this.clearVideoSources();
        this.toggleOriginalContentVisibility(false);
        this.deleteAllRenders();

        if (Gallery.settings.debugEnabled) {
          Array.from(getAllThumbs()).forEach((thumb) => {
            thumb.classList.remove("loaded");
            thumb.classList.remove("debug-selected");
          });
        }
      }
      this.onPageChange();
    });
    window.addEventListener("foundFavorite", (event) => {
      this.foundFavoriteId = event.detail;
    });
    window.addEventListener("shuffle", () => {
      this.enumerateVisibleThumbs();
      this.deleteAllRenders();
      this.renderImagesInTheBackground();
    });
    window.addEventListener("favoriteMetadataFetched", (event) => {
      this.assignImageExtension(event.detail.id, event.detail.extension);
    });
    window.addEventListener("didNotChangePageInGallery", (event) => {
      if (this.inGallery) {
        this.setNextSelectedThumbIndex(event.detail);
        this.traverseGalleryHelper();
      }
    });
  }

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

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

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

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

        default:
          break;
      }
    };
  }

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

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

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

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

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

    if (!this.inGallery) {
      return;
    }

    const thumb = this.getSelectedThumb();

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

  addMemoryManagementEventListeners() {
    if (onFavoritesPage()) {
      return;
    }
    window.addEventListener("blur", () => {
      this.leftPage = true;
      this.deleteAllRenders();
      this.clearInactiveVideoSources();
    });
    window.addEventListener("focus", () => {
      if (this.leftPage) {
        this.renderImagesInTheBackground();
        this.leftPage = false;
      }
    });
  }

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

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

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

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

  insertHTML() {
    this.insertStyleHTML();
    this.insertDebugHTML();
    this.insertOptionsHTML();
    this.insertOriginalContentContainerHTML();

  }

  insertStyleHTML() {
    insertStyleHTML(galleryHTML);
  }

  insertDebugHTML() {
    if (Gallery.settings.debugEnabled) {
      insertStyleHTML(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) => {
      setPreference(Gallery.preferences.showOnHover, event.target.checked);
      this.toggleAllVisibility(event.target.checked);
    };

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

  insertOriginalContentContainerHTML() {
    const originalContentContainerHTML = `
          <div id="original-content-container">
              <div id="original-video-container">
                <video id="video-player-0" width="100%" height="100%" autoplay muted loop controlsList="nofullscreen" active></video>
              </div>
              <img id="original-gif-container" class="focused"></img>
              <div id="original-content-background"></div>
          </div>
      `;

    document.body.insertAdjacentHTML("afterbegin", originalContentContainerHTML);
    this.originalContentContainer = document.getElementById("original-content-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() {
    for (const video of this.videoPlayers) {
      video.addEventListener("mousemove", () => {
        if (!video.hasAttribute("controls")) {
          video.setAttribute("controls", "");
        }
      }, {
        passive: true
      });
      video.addEventListener("click", () => {
        if (video.paused) {
          video.play().catch(() => { });
        } else {
          video.pause();
        }
      }, {
        passive: true
      });
      video.addEventListener("volumechange", (event) => {
        if (!event.target.hasAttribute("active")) {
          return;
        }
        setPreference(Gallery.preferences.videoVolume, video.volume);
        setPreference(Gallery.preferences.videoMuted, video.muted);

        for (const v of this.getInactiveVideoPlayers()) {
          v.volume = video.volume;
          v.muted = video.muted;
        }
      }, {
        passive: true
      });
      video.addEventListener("ended", () => {
        this.autoplayController.onVideoEnded();
      }, {
        passive: true
      });
      video.addEventListener("dblclick", () => {
        if (this.inGallery && !this.recentlyEnteredGallery) {
          this.exitGallery();
          this.toggleAllVisibility(false);
        }
      });
    }
  }

  addBackgroundEventListeners() {
    if (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(getPreference(Gallery.preferences.videoVolume, 1));
    video.muted = getPreference(Gallery.preferences.videoMuted, true);
  }

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

  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 && isVideo(this.getSelectedThumb())) {
          this.playOriginalVideo(this.getSelectedThumb());
        }
      }
    );

    this.autoplayController = new Autoplay(subscribers);
  }

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

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

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);

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

    this.renderImages(imageThumbsToRender);
  }

  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 = 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 thumbNode = ThumbNode.allThumbNodes.get(message.id);

        if (thumbNode !== undefined && thumbNode.root !== undefined) {
          thumbNode.root.classList.add("loaded");
        }
      }
    }

    if (thumb !== null && message.extension === "gif") {
      getImageFromThumb(thumb).setAttribute("gif", true);
      return;
    }
    this.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 && 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 thumbNode of ThumbNode.allThumbNodes.values()) {
          if (thumbNode.root !== undefined) {
            thumbNode.root.classList.remove("loaded");
          }
        }
      }
    }
  }

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

    for (const id of this.transferredCanvases.keys()) {
      this.transferredCanvases.get(id).remove();
      this.transferredCanvases.delete(id);
    }
    this.transferredCanvases.clear();
  }

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

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

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

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

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

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

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

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

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

          if (!isAnImage || isBlacklisted) {
            continue;
          }
          const id = post.getAttribute("id");
          const extension = getExtensionFromImageURL(originalImageURL);

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

  async findImageExtensionsInTheBackground() {
    await sleep(1000);
    const idsWithUnknownExtensions = this.getIdsWithUnknownExtensions(Array.from(ThumbNode.allThumbNodes.values()));

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

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

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

  /**
   * @param {String} id
   * @param {String} extension
   */
  assignImageExtension(id, extension) {
    if (this.imageExtensions[parseInt(id)] !== undefined) {
      return;
    }
    Gallery.imageExtensionAssignmentCooldown.restart();
    this.setImageExtension(id, extension);
    this.updateStoredImageExtensions();
  }

  updateStoredImageExtensions() {
    this.recentlyDiscoveredImageExtensionCount += 1;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /**
   *
   * @param {HTMLElement} thumb
   */
  openPostInNewPage(thumb) {
    thumb = thumb === undefined ? this.getSelectedThumb() : thumb;
    const firstChild = thumb.children[0];

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

  unFavoriteSelectedContent() {
    if (!userIsOnTheirOwnFavoritesPage()) {
      return;
    }
    const selectedThumb = this.getSelectedThumb();

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

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

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

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

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

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

    this.lastSelectedThumbIndexBeforeEnteringGallery = this.currentlySelectedThumbIndex;
    this.background.style.pointerEvents = "auto";

    if (isVideo(selectedThumb)) {
      this.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);
  }

  exitGallery() {
    if (Gallery.settings.debugEnabled) {
      getAllVisibleThumbs().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();
    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 (!usingFirefox()) {
      scrollToThumb(selectedThumb.id, false, true);
    }

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

  /**
   * @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
    }));
    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 isVideo(this.thumbUnderCursor);
  }

  /**
   * @param {HTMLElement} thumb
   * @returns {Number}
   */
  getIndexFromThumb(thumb) {
    return parseInt(thumb.getAttribute(Gallery.htmlAttributes.thumbIndex));
  }

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalContent(thumb) {
    this.currentlySelectedThumbIndex = this.getIndexFromThumb(thumb);
    this.upscaleAnimatedThumbsAroundDiscrete(thumb);

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

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

    if (this.showOriginalContentOnHover) {
      this.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 isVideo(t) && t.id !== initialThumb.id;
      }
    );

  }
  /**
   * @param {HTMLElement} initialThumb
   * @param {Number} limit
   * @returns {HTMLElement[]}
   */
  getAdjacentVideoThumbsThroughoutAllPages(initialThumb, limit) {
    return this.getAdjacentSearchResults(
      initialThumb,
      limit,
      (t) => {
        return isVideo(t) && t.id !== initialThumb.id;
      }
    );
  }

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

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

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

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

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

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

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

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

    if (match === null) {
      return null;
    }
    return match[1];
  }

  /**
   * @param {HTMLElement} thumb
   */
  playOriginalVideo(thumb) {
    this.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 = getTagsFromThumb(thumb);
    const extension = tags.has("animated_png") ? "png" : "gif";
    const originalSource = getOriginalImageURLFromThumb(thumb).replace("jpg", extension);

    this.gifContainer.src = originalSource;

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

  /**
   * @param {HTMLElement} thumb
   */
  showOriginalImage(thumb) {
    if (this.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 (onSearchPage() || (onMobileDevice() && !this.enlargeOnClickOnMobile)) {
      return;
    }
    this.renderImages(this.getAdjacentImageThumbs(initialThumb));
  }

  /**
   * @param {HTMLElement} initialThumb
   * @returns {HTMLElement[]}
   */
  getAdjacentImageThumbs(initialThumb) {
    const adjacentImageThumbs = 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 isImage(thumb);
      }
    );
  }

  /**
   * @param {HTMLElement} initialThumb
   * @returns {HTMLElement[]}
   */
  getAdjacentImageThumbThroughoutAllPages(initialThumb) {
    return this.getAdjacentSearchResults(
      initialThumb,
      Gallery.settings.maxImagesToRenderAround,
      (thumbNode) => {
        return isImage(thumbNode);
      }
    );
  }

  /**
   * @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(thumbNode => thumbNode.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(thumbNode => thumbNode.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 !onMobileDevice() && !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: getOriginalImageURLFromThumb(thumb),
      id: thumb.id,
      extension: this.getImageExtension(thumb.id),
      fetchDelay: this.getBaseImageFetchDelay(thumb.id),
      thumbURL: getImageFromThumb(thumb).src.replace("us.rule", "rule"),
      pixelCount: this.getPixelCount(thumb),
      resolutionFraction: Gallery.settings.upscaledThumbResolutionFraction
    };

    this.startedRenders.add(thumb.id);

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

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

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

  /**
   * @param {HTMLElement} thumb
   */
  renderOriginalImage(thumb) {
    if (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 (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 (onMobileDevice()) {
      return;
    }
    const upscaleRequests = [];

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

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

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

  /**
   * @param {String} id
   * @returns {Number}
   */
  getBaseImageFetchDelay(id) {
    if (onFavoritesPage() && !Gallery.finishedLoading) {
      return Gallery.settings.throttledImageFetchDelay;
    }

    if (this.extensionIsKnown(id)) {
      return Gallery.settings.imageFetchDelayWhenExtensionKnown;
    }
    return Gallery.settings.imageFetchDelay;
  }

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

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

    this.upscaleAnimatedThumbs(animatedThumbsToUpscale);
  }

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

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

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

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

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

  /**
   * @param {Boolean} value
   */
  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) {
      showFullscreenIcon(ICONS.error);
      return;
    }
    const addedFavoriteStatus = await addFavorite(selectedThumb.id);
    let svg = ICONS.error;

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

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

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

const gallery = new Gallery();


// tooltip.js

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

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

class Tooltip {
  /**
   * @type {Boolean}
   */
  static get disabled() {
    return onMobileDevice() || getPerformanceProfile() > 1 || onPostPage();
  }

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

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

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

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

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

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

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

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

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

  addFavoritesPageEventListeners() {
    if (!onFavoritesPage()) {
      return;
    }
    window.addEventListener("favoritesFetched", () => {
      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 (usingDarkTheme()) {
      this.tooltip.classList.remove("light-green-gradient");
      this.tooltip.classList.add("dark-green-gradient");
    }
  }

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

    }
  }

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

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

      if (image.onmouseenter !== null) {
        continue;
      }

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

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

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

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

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

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

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

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

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

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

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

  /**
   * @param {HTMLImageElement} image
   */
  show(image) {
    this.setText(this.getTagsFromImageWithIdRemoved(image));
    this.setPosition(image);
  }

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

  /**
   * @param {HTMLImageElement} image
   * @returns {String}
   */
  getTagsFromImageWithIdRemoved(image) {
    const thumb = getThumbFromImage(image);
    const tags = getTagsFromThumb(thumb);

    if (this.searchTagColorCodes[thumb.id] === undefined) {
      tags.delete(thumb.id);
    }
    return Array.from(tags).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[]} allTags
   */
  formatHTML(allTags) {
    let html = "";
    const tags = allTags.split(" ");

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  addFavoritesOptions() {
    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;
    }
    setPreference("showTooltip", value);
    this.visible = value;
  }

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

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

    this.assignTagColors(searchQuery);
  }

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

const tooltip = new Tooltip();


// saved_searches.js

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

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

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

    #savedSearches {
      max-width: 100%;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /* .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 id="export-saved-search-button">Export</button>
      <button id="import-saved-search-button">Import</button>
    </span>
    <button title="Save result ids as search" id="save-results-button">Save Results</button>
  </div>
  <div id="saved-searches-container">
    <div id="saved-searches-input-container">
      <textarea id="saved-searches-input" spellcheck="false" style="width: 97%;"
        placeholder="Save Custom Search"></textarea>
    </div>
    <div id="saved-search-list-container">
      <ul id="saved-search-list"></ul>
    </div>
  </div>
</div>
`;

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

  constructor() {
    if (SavedSearches.disabled) {
      return;
    }
    this.initialize();
  }

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

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

    savedSearchesContainer.insertAdjacentHTML("beforeend", savedSearchesHTML);
    document.getElementById("right-favorites-panel").style.display = showSavedSearches ? "block" : "none";
    const options = createFavoritesOption(
      "show-saved-searches",
      "Saved Searches",
      "Toggle saved searches",
      showSavedSearches,
      (e) => {
        savedSearchesContainer.style.display = e.target.checked ? "block" : "none";
        setPreference(SavedSearches.preferences.visibility, e.target.checked);
      },
      true
    );

    document.getElementById("bottom-panel-2").insertAdjacentElement("afterbegin", options);
  }

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

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

        default:
          break;
      }
    }, {
      passive: true
    });
    this.exportButton.onclick = () => {
      this.exportSavedSearches();
    };
    this.importButton.onclick = () => {
      this.importSavedSearches();
    };
    this.saveSearchResultsButton.onclick = () => {
      this.saveSearchResultsAsCustomSearch();
    };
  }

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

    savedSearchLabel.innerText = newSavedSearch;
    editButton.innerHTML = ICONS.edit;
    removeButton.innerHTML = ICONS.delete;
    moveToTopButton.innerHTML = ICONS.upArrow;
    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(getSavedSearchValues()));
  }

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

    setPreference(SavedSearches.preferences.tutorial, false);

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

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

  createTutorialSearches() {
    const searches = [];

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

      shuffleArray(postIds);

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

      searches.push(exampleSearch);

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

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

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

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

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

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

      this.savedSearchesList.innerHTML = "";

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

  saveSearchResultsAsCustomSearch() {
    const searchResultIds = Array.from(ThumbNode.allThumbNodes.values())
      .filter(thumbNode => thumbNode.matchedByMostRecentSearch)
      .map(thumbNode => thumbNode.id);

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

    if (searchResultIds.length > 300) {
      if (!confirm(`Are you sure you want to save ${searchResultIds.length} ids as one search?`)) {
        return;
      }
    }
    const customSearch = `( ${searchResultIds.join(" ~ ")} )`;

    this.saveSearch(customSearch);
  }
}

const savedSearches = new SavedSearches();


// caption.js

const captionHTML = `<style>
  .caption {
    overflow: hidden;
    pointer-events: none;
    background: rgba(0, 0, 0, .75);
    z-index: 15;
    position: absolute;
    width: 100%;
    height: 100%;
    top: -100%;
    left: 0px;
    top: 0px;
    text-align: left;
    transform: translateX(-100%);
    /* transition: transform .3s cubic-bezier(.26,.28,.2,.82); */
    transition: transform .35s ease;
    padding-top: 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>`;

class Caption {
  static preferences = {
    visibility: "showCaptions"
  };
  static localStorageKeys = {
    tagCategories: "tagCategories"
  };
  static importantTagCategories = new Set([
    "copyright",
    "character",
    "artist",
    "metadata"
  ]);
  static tagCategoryEncodings = {
    0: "general",
    1: "artist",
    2: "unknown",
    3: "copyright",
    4: "character",
    5: "metadata"
  };
  static template = `
     <ul id="caption-list">
         <li id="caption-id" style="display: block;"><h6>ID</h6></li>
         ${Caption.getCategoryHeaderHTML()}
     </ul>
 `;
  static findCategoriesOnPageChangeCooldown = new Cooldown(3000, true);
  /**
   * @type {Object.<String, Number>}
   */
  static tagCategoryAssociations;
  static settings = {
    tagFetchDelayAfterFinishedLoading: 5,
    tagFetchDelayBeforeFinishedLoading: 100
  };
  static flags = {
    finishedLoading: false
  };

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

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

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

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

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

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

  /**
   * @type {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();
    };
    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() {
    insertStyleHTML(captionHTML);
    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");
    }
    setPreference(Caption.preferences.visibility, value);
  }

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

  addAllPageEventListeners() {
    this.caption.addEventListener("transitionend", () => {
      if (this.caption.classList.contains("active")) {
        this.caption.classList.add("transition-completed");
      }
      this.caption.classList.remove("transitioning");
    });
    this.caption.addEventListener("transitionstart", () => {
      this.caption.classList.add("transitioning");
    });
    window.addEventListener("showOriginalContent", (event) => {
      const thumb = caption.parentElement;

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

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

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

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

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

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

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

  addFavoritesPageEventListeners() {
    window.addEventListener("favoritesLoaded", () => {
      this.addEventListenersToThumbs.bind(this)();
      Caption.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 => getImageFromThumb(thumb).title)
        .join(" ")
        .split(" ")
        .filter(tagName => !isNumber(tagName) && Caption.tagCategoryAssociations[tagName] === undefined);

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

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

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

      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.stopPropagation();
      event.preventDefault();
    };
    captionIdTag.addEventListener("contextmenu", (event) => {
      event.preventDefault();
      event.stopPropagation();
    });

    captionIdTag.onmousedown = (event) => {
      this.tagOnClick(thumb.id, event);
    };
    captionIdHeader.insertAdjacentElement("afterend", captionIdTag);
    thumb.children[0].appendChild(this.captionWrapper);
    this.populateTags(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   */
  removeFromThumb(thumb) {
    if (this.hidden) {
      return;
    }

    this.removeFromThumbHelper(thumb);
  }

  /**
   * @param {HTMLElement} thumb
   */
  removeFromThumbHelper(thumb) {
    if (thumb !== null && thumb !== undefined) {
      this.animateRemoval(thumb);
    }
    this.animate(false);
    this.caption.classList.add("inactive");
    this.caption.classList.remove("transition-completed");
  }

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

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

  /**
   * @param {HTMLElement} thumb
   */
  resizeFont(thumb) {
    const columnInput = document.getElementById("column-resize-input");
    const heightCanBeDerivedWithoutRect = this.thumbMetadataExists(thumb) && columnInput !== null;
    let height;

    if (heightCanBeDerivedWithoutRect) {
      height = this.estimateThumbHeightFromMetadata(thumb, columnInput);
    } else {
      height = getImageFromThumb(thumb).getBoundingClientRect().height;
    }
    const captionListRect = this.caption.children[0].getBoundingClientRect();
    const ratio = height / captionListRect.height;
    const scale = ratio > 1 ? Math.sqrt(ratio) : ratio * 0.85;

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

  /**
   * @param {HTMLElement} thumb
   * @returns {Boolean}
   */
  thumbMetadataExists(thumb) {
    if (onSearchPage()) {
      return false;
    }
    const thumbNode = ThumbNode.allThumbNodes.get(thumb.id);

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

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

    if (thumbNode.metadata.width <= 0 || thumbNode.metadata.width <= 0) {
      return false;
    }
    return true;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {HTMLInputElement} columnInput
   * @returns {Number}
   */
  estimateThumbHeightFromMetadata(thumb, columnInput) {
    const thumbNode = ThumbNode.allThumbNodes.get(thumb.id);
    const gridGap = 16;
    const columnCount = Math.max(1, parseInt(columnInput.value));
    const thumbWidthEstimate = (window.innerWidth - (columnCount * gridGap)) / columnCount;
    const thumbWidthScale = thumbNode.metadata.width / thumbWidthEstimate;
    return thumbNode.metadata.height / thumbWidthScale;
  }

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

    tag.className = `${tagCategory}-tag caption-tag`;
    tag.textContent = this.replaceUnderscoresWithSpaces(tagName);
    header.insertAdjacentElement("afterend", tag);
    header.style.display = "block";
    tag.onmouseover = (event) => {
      event.stopPropagation();
    };
    tag.onclick = (event) => {
      event.stopPropagation();
      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) || "{}");
  }

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

  /**
   * @param {String} tagName
   * @param {MouseEvent} event
   */
  tagOnClick(tagName, event) {
    switch (event.button) {
      case CLICK_CODES.left:
        this.tagOnClickHelper(tagName, event);
        break;

      case CLICK_CODES.middle:
        dispatchEvent(new CustomEvent("searchForTag", {
          detail: tagName
        }));
        break;

      case CLICK_CODES.right:
        this.tagOnClickHelper(`-${tagName}`, event);
        break;

      default:
        break;
    }
  }

  /**
   * @param {String} value
   * @param {MouseEvent} mouseEvent
   */
  tagOnClickHelper(value, mouseEvent) {
    if (mouseEvent.ctrlKey) {
      openSearchPage(value);
      return;
    }
    const searchBox = onSearchPage() ? document.getElementsByName("tags")[0] : document.getElementById("favorites-search-box");
    const searchBoxDoesNotIncludeTag = true;

    navigator.clipboard.writeText(value);

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

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

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

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

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

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

  /**
   * @param {HTMLElement} thumb
   */
  populateTags(thumb) {
    const tagNames = 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) {
    this.saveTags();

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

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

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

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

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

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

  /**
   * @param {String[]} tags
   * @returns {Boolean}
   */
  allTagsAreProblematic(tags) {
    for (const tag of tags) {
      if (!this.problematicTags.has(tag)) {
        return false;
      }
    }
    return tags.length > 0;
  }

  /**
   * @param {HTMLElement} thumb
   * @param {Function} onProblematicTagsCorrected
   */
  correctAllProblematicTagsFromThumb(thumb, onProblematicTagsCorrected) {
    fetch(`https://rule34.xxx/index.php?page=post&s=view&id=${thumb.id}`)
      .then((response) => {
        return response.text();
      })
      .then((html) => {
        const tagCategoryMap = this.getTagCategoryMapFromPostPage(html);

        for (const [tagName, tagCategory] of tagCategoryMap.entries()) {
          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 && !CUSTOM_TAGS.has(tag)) {
      this.problematicTags.add(tag);
    }
  }

  findTagCategoriesOnPageChange() {
    const tagNames = this.getTagNamesWithUnknownCategories(getAllVisibleThumbs().slice(0, 200));

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

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

    for (const tagName of uniqueTagNames) {
      if (isNumber(tagName) && tagName.length > 5) {
        Caption.tagCategoryAssociations[tagName] = 0;
        continue;
      }

      if (tagName.includes("'")) {
        this.setAsProblematic(tagName);
      }

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

  /**
   * @param {HTMLElement[]} thumbs
   * @returns {String[]}
   */
  getTagNamesWithUnknownCategories(thumbs) {
    const tagNamesWithUnknownCategories = new Set();

    for (const thumb of thumbs) {
      const tagNames = Array.from(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 && !CUSTOM_TAGS.has(tagName);
  }
}

const caption = new Caption();


// tag_modifier.js

const tagModifierHTML = `<div id="tag-modifier-container">
  <style>
    #tag-modifier-ui-container {
      display: none;

      >* {
        margin-top: 10px;
      }
    }

    #tag-modifier-ui-textarea {
      width: 80%;
    }

    .thumb-node.tag-modifier-selected {
      outline: 2px dashed white !important;

      >div {
        opacity: 1;
        filter: grayscale(0%);
      }
    }

    #tag-modifier-ui-status-label {
      visibility: hidden;
    }

    .tag-type-custom>a,
    .tag-type-custom {
      color: hotpink;
    }
  </style>
  <div id="tag-modifier-option-container">
    <label class="checkbox" title="Add or Remove custom or official tags to favorites">
      <input type="checkbox" id="tag-modifier-option-checkbox">Modify Tags<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>

`;

class TagModifier {
  /**
   * @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 (onMobileDevice()) {
      return true;
    }

    if (onFavoritesPage()) {
      return false;
    }
    return 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 {ThumbNode[]}
   */
  selectedThumbNodes;
  /**
   * @type {Boolean}
   */
  atLeastOneFavoriteIsSelected;

  constructor() {
    if (TagModifier.disabled) {
      return;
    }
    this.tagEditModeAbortController = new AbortController();
    this.favoritesOption = {};
    this.favoritesUI = {};
    this.selectedThumbNodes = [];
    this.atLeastOneFavoriteIsSelected = false;
    this.loadTagModifications();
    this.insertHTML();
    this.addEventListeners();
  }

  insertHTML() {
    this.insertFavoritesPageHTML();
    this.insertSearchPageHTML();
    this.insertPostPageHTML();
  }

  insertFavoritesPageHTML() {
    if (!onFavoritesPage()) {
      return;
    }
    document.getElementById("bottom-panel-4").insertAdjacentHTML("beforeend", 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 (!onSearchPage()) {
      return;
    }
    1;
  }

  insertPostPageHTML() {
    if (!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 CUSTOM_TAGS.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 (!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 (!onSearchPage()) {
      return;
    }
    1;
  }

  addPostPageEventListeners() {
    if (!onPostPage()) {
      return;
    }
    1;
  }

  highlightSelectedThumbsOnPageChange() {
    if (!this.atLeastOneFavoriteIsSelected) {
      return;
    }
    const thumbNodes = Array.from(getAllThumbs())
      .map(thumb => ThumbNode.allThumbNodes.get(thumb.id));

    for (const thumbNode of thumbNodes) {
      if (thumbNode === undefined) {
        return;
      }

      if (this.isSelectedForModification(thumbNode)) {
        this.highlightThumbNode(thumbNode, true);
      }
    }
  }

  /**
   * @param {Boolean} value
   */
  toggleTagEditMode(value) {
    this.toggleThumbInteraction(value);
    this.toggleUi(value);
    this.toggleTagEditModeEventListeners(value);
    this.favoritesUI.unSelectAll.click();
  }

  /**
   * @param {Boolean} value
   */
  toggleThumbInteraction(value) {
    if (!value) {
      const tagEditModeStyle = document.getElementById("tag-edit-mode");

      if (tagEditModeStyle !== null) {
        tagEditModeStyle.remove();
      }
      return;
    }
    insertStyleHTML(`
      .thumb-node  {
        cursor: pointer;
        outline: 1px solid black;

        > div {
          outline: none !important;

          > img {
            outline: none !important;
          }

          pointer-events:none;
          opacity: 0.6;
          filter: grayscale(90%);
          transition: none !important;
        }
      }
    `, "tag-edit-mode");
  }

  /**
   * @param {Boolean} value
   */
  toggleUi(value) {
    this.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("thumb-node")) {
        return;
      }
      const thumbNode = ThumbNode.allThumbNodes.get(event.target.id);

      if (thumbNode !== undefined) {
        this.toggleThumbSelection(thumbNode);
      }
    }, {
      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 thumbNode of ThumbNode.allThumbNodes.values()) {
      this.toggleThumbSelection(thumbNode, false);
    }
    this.atLeastOneFavoriteIsSelected = false;
  }

  selectAll() {
    for (const thumbNode of ThumbNode.thumbNodesMatchedBySearch.values()) {
      this.toggleThumbSelection(thumbNode, true);
    }
  }

  /**
   * @param {ThumbNode} thumbNode
   * @param {Boolean} value
   */
  toggleThumbSelection(thumbNode, value) {
    this.atLeastOneFavoriteIsSelected = true;

    if (value === undefined) {
      value = !this.isSelectedForModification(thumbNode);
    }
    thumbNode.selectedForTagModification = value ? true : undefined;
    this.highlightThumbNode(thumbNode, value);
  }

  /**
   * @param {ThumbNode} thumbNode
   * @param {Boolean} value
   */
  highlightThumbNode(thumbNode, value) {
    if (thumbNode.root !== undefined) {
      thumbNode.root.classList.toggle("tag-modifier-selected", value);
    }
  }

  /**
   * @param {ThumbNode} thumbNode
   * @returns {Boolean}
   */
  isSelectedForModification(thumbNode) {
    return thumbNode.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 = removeExtraWhiteSpace(tagsWithoutContentTypes);
    const statusPrefix = remove ? "Removed tag(s) from" : "Added tag(s) to";
    let modifiedTagsCount = 0;

    if (tagsToModify === "") {
      return;
    }

    for (const thumbNode of ThumbNode.allThumbNodes.values()) {
      if (this.isSelectedForModification(thumbNode)) {
        const additionalTags = remove ? thumbNode.removeAdditionalTags(tagsToModify) : thumbNode.addAdditionalTags(tagsToModify);

        TagModifier.tagModifications.set(thumbNode.id, additionalTags);
        modifiedTagsCount += 1;
      }
    }

    if (modifiedTagsCount === 0) {
      return;
    }

    if (tags !== tagsWithoutContentTypes) {
      alert("Warning: video, animated, and mp4 tags are unchanged.\nThey cannot be modified.");
    }
    this.showStatus(`${statusPrefix} ${modifiedTagsCount} favorite(s)`);
    dispatchEvent(new Event("modifiedTags"));
    setCustomTags(tagsToModify);
    this.storeTagModifications();
  }

  createDatabase(event) {
    /**
     * @type {IDBDatabase}
     */
    const database = event.target.result;

    database
      .createObjectStore(TagModifier.objectStoreName, {
        keyPath: "id"
      });
  }

  storeTagModifications() {
    const request = indexedDB.open(TagModifier.databaseName, 1);

    request.onupgradeneeded = this.createDatabase;
    request.onsuccess = (event) => {
      /**
       * @type {IDBDatabase}
       */
      const database = event.target.result;
      const objectStore = database
        .transaction(TagModifier.objectStoreName, "readwrite")
        .objectStore(TagModifier.objectStoreName);
      const idsWithNoTagModifications = [];

      for (const [id, tags] of TagModifier.tagModifications) {
        if (tags === "") {
          idsWithNoTagModifications.push(id);
          objectStore.delete(id);
        } else {
          objectStore.put({
            id,
            tags
          });
        }
      }

      for (const id of idsWithNoTagModifications) {
        TagModifier.tagModifications.delete(id);
      }
      database.close();
    };
  }

  loadTagModifications() {
    const request = indexedDB.open(TagModifier.databaseName, 1);

    request.onupgradeneeded = this.createDatabase;
    request.onsuccess = (event) => {
      /**
       * @type {IDBDatabase}
       */
      const database = event.target.result;
      const objectStore = database
        .transaction(TagModifier.objectStoreName, "readonly")
        .objectStore(TagModifier.objectStoreName);

      objectStore.getAll().onsuccess = (successEvent) => {
        const tagModifications = successEvent.target.result;

        for (const record of tagModifications) {
          TagModifier.tagModifications.set(record.id, record.tags);
        }
      };
      database.close();
    };
  }

  resetTagModifications() {
    if (!confirm("Are you sure you want to delete all tag modifications?")) {
      return;
    }
    CUSTOM_TAGS.clear();
    indexedDB.deleteDatabase("AdditionalTags");
    ThumbNode.allThumbNodes.forEach(thumbNode => {
      thumbNode.resetAdditionalTags();
    });
    dispatchEvent(new Event("modifiedTags"));
    localStorage.removeItem("customTags");
  }

  exportTagModifications() {
    const modifications = JSON.stringify(mapToObject(TagModifier.tagModifications));

    navigator.clipboard.writeText(modifications);
    alert("Copied tag modifications to clipboard");
  }

  importTagModifications() {
    let modifications;

    try {
      const object = JSON.parse(this.favoritesUI.textarea.value);

      if (!(typeof object === "object")) {
        throw new TypeError(`Input parsed as ${typeof (object)}, but expected object`);
      }
      modifications = objectToMap(object);
    } catch (error) {
      if (error.name === "SyntaxError" || error.name === "TypeError") {
        alert("Import Unsuccessful. Failed to parse input, JSON object format expected.");
      } else {
        throw error;
      }
      return;
    }
    console.error(modifications);
  }
}

const tagModifier = new TagModifier();


// awesomplete.min.js

// Awesomplete - Lea Verou - MIT license
!(function () {
    function t(t) {
        const e = Array.isArray(t) ? {
            label: t[0],
            value: t[1]
        } : typeof t === "object" && t != null && "label" in t && "value" in t ? t : {
            label: t,
            value: t
        };

        this.label = e.label || e.value, this.value = e.value, this.type = e.type;
    }

    function e(t, e, i) {
        for (const n in e) {
            const s = e[n],
                r = t.input.getAttribute(`data-${n.toLowerCase()}`);

            typeof s === "number" ? t[n] = parseInt(r) : !1 === s ? t[n] = r !== null : s instanceof Function ? t[n] = null : t[n] = r, t[n] || t[n] === 0 || (t[n] = n in i ? i[n] : s);
        }
    }

    function i(t, e) {
        return typeof t === "string" ? (e || document).querySelector(t) : t || null;
    }

    function n(t, e) {
        return o.call((e || document).querySelectorAll(t));
    }

    function s() {
        n("input.awesomplete").forEach((t) => {
            new r(t);
        });
    }

    var r = function (t, n) {
        const s = this;

        this.isOpened = !1, this.input = i(t), this.input.setAttribute("autocomplete", "off"), this.input.setAttribute("aria-autocomplete", "list"), n = n || {}, e(this, {
            minChars: 2,
            maxItems: 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;
}());


// awesomplete.js

/* eslint-disable new-cap */
class AwesompleteWrapper {
  static preferences = {
    savedSearchSuggestions: "savedSearchSuggestions"
  };

  /**
   * @type {Boolean}
   */
  static get disabled() {
    return !onFavoritesPage();
  }

  /**
   * @type {Boolean}
   */
  showSavedSearchSuggestions;

  constructor() {
    if (AwesompleteWrapper.disabled) {
      return;
    }
    this.initializeFields();
    this.insertHTML();
    this.addAwesompleteToInputs();
  }

  initializeFields() {
    this.showSavedSearchSuggestions = getPreference(AwesompleteWrapper.preferences.savedSearchSuggestions, false);
  }

  insertHTML() {
    createFavoritesOption(
      "show-saved-search-suggestions",
      "Saved Suggestions",
      "Show saved search suggestions in autocomplete dropdown",
      this.showSavedSearchSuggestions,
      (event) => {
        this.showSavedSearchSuggestions = event.target.checked;
        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, _) => {
        return Awesomplete_.FILTER_STARTSWITH(suggestion.value, this.getCurrentTag(awesomplete.input));
      },
      sort: false,
      item: (suggestion, tags) => {
        const html = tags.trim() === "" ? suggestion.label : suggestion.label.replace(RegExp(Awesomplete_.$.regExpEscape(tags.trim()), "gi"), "<mark>$&</mark>");
        return Awesomplete_.$.create("li", {
          innerHTML: html,
          "aria-selected": "false",
          className: `tag-type-${suggestion.type}`
        });
      },
      replace: (suggestion) => {
        insertSuggestion(awesomplete.input, 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":
          hideAwesomplete(input);
          break;

        default:
          break;
      }
    });

    input.oninput = () => {
      this.populateAwesompleteList(input.id, this.getCurrentTagWithHyphen(input), awesomplete);
    };
  }

  getSavedSearchesForAutocompleteList(inputId, prefix) {
    if (!this.showSavedSearchSuggestions || inputId !== "favorites-search-box") {
      return [];
    }
    return 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 = 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];
  }
}

const awesomplete = new AwesompleteWrapper();