Danbooru

[Userscript] Danbooru EX

Posted under General

I've updated some patch from the latest version:
- change light mode to standard dark mode ( hardcoded )
- fix header height issue

To apply it, edit your currently installed dbex userscript and replace all of it with this one:

dbex userscript
// ==UserScript==
// @name         Danbooru EX
// @version      2018.08.21@19.56.54
// @namespace    https://github.com/evazion/danbooru-ex
// @source       https://github.com/evazion/danbooru-ex
// @description  Danbooru UI Enhancements
// @author       evazion
// @match        *://*.donmai.us/*
// @grant        none
// @run-at       document-body
// @downloadURL  https://github.com/evazion/danbooru-ex/raw/stable/dist/danbooru-ex.user.js
// @require      https://raw.githubusercontent.com/jquery/jquery-ui/1.12.1/ui/widgets/selectable.js
// @require      https://raw.githubusercontent.com/jquery/jquery-ui/1.12.1/ui/widgets/tooltip.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.19.1/moment.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.js
// @require      https://unpkg.com/filesize@3.5.11
// @require      https://unpkg.com/css-element-queries@0.3.2/src/ResizeSensor.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/qtip2/3.0.3/jquery.qtip.js
// @require      https://unpkg.com/mousetrap@1.6.0/mousetrap.js
// @require      https://unpkg.com/mousetrap@1.6.0/plugins/record/mousetrap-record.js
// @require      https://unpkg.com/mousetrap@1.6.0/plugins/global-bind/mousetrap-global-bind.js
// ==/UserScript==

/*
 * What is a userscript? A miserable pile of hacks.
 */

console.log("Danbooru EX:", GM_info.script.version);
// console.time("loaded");
// console.time("preinit");
// console.time("initialized");

var danbooruEX = (function (_,Mousetrap,moment,ResizeSensor) {
'use strict';

function ___$insertStyle(css) {
  if (!css) {
    return;
  }
  if (typeof window === 'undefined') {
    return;
  }

  var style = document.createElement('style');

  style.setAttribute('type', 'text/css');
  style.innerHTML = css;
  document.head.appendChild(style);

  return css;
}

_ = _ && _.hasOwnProperty('default') ? _['default'] : _;
Mousetrap = Mousetrap && Mousetrap.hasOwnProperty('default') ? Mousetrap['default'] : Mousetrap;
moment = moment && moment.hasOwnProperty('default') ? moment['default'] : moment;
ResizeSensor = ResizeSensor && ResizeSensor.hasOwnProperty('default') ? ResizeSensor['default'] : ResizeSensor;

class Setting {
  constructor({ value, help, configurable, storage } = {}) {
    this.value = value;
    this.help = help;
    this.configurable = configurable;
    this.storage = storage;
  }

  static Session({ value } = {}) {
    return new Setting({ value, help: "none", configurable: false, storage: window.sessionStorage });
  }

  static Shared({ value = true, help, configurable = true } = {}) {
    return new Setting({ value, help, configurable, storage: window.localStorage });
  }
}

class Config {
  static get Items() {
    return {
      schemaVersion: Setting.Shared({
        configurable: false,
        value: 2
      }),

      enableHeader: Setting.Shared({
        help: "Enable header bar containing search box and mode menu.",
      }),
      enableModeMenu: Setting.Shared({
        help: "Enable mode menu in header bar, disable mode menu in the sidebar. Required for preview panel.",
      }),
      enablePreviewPanel: Setting.Shared({
        help: "Enable the post preview panel. Requires header bar and mode menu to be enabled.",
      }),
      defaultPreviewMode: Setting.Shared({
        help: "Open new tabs in preview mode",
        value: false,
      }),
      enableHotkeys: Setting.Shared({
        help: "Enable additional keyboard shortcuts.",
      }),
      enableLargeThumbnails: Setting.Shared({
        configurable: false,
        help: "Enable extra large thumbnails (experimental; bandwidth intensive).",
        value: false,
      }),
      largeThumbnailSize: Setting.Shared({
        configurable: false,
        help: "The size (in pixels) of large thumbnails.",
        value: 229,
      }),
      showThumbnailPreviews: Setting.Shared({
        help: "Show post preview tooltips when hovering over thumbnails.",
      }),
      showPostLinkPreviews: Setting.Shared({
        help: "Show post preview tooltips when hovering over post #1234 links.",
      }),
      enableNotesLivePreview: Setting.Shared({
        help: "Automatically update note preview as you edit.",
      }),
      usernameTooltips: Setting.Shared({
        help: "Enable tooltips on usernames",
        value: false,
      }),
      styleWikiLinks: Setting.Shared({
        help: "Colorize tags in the wiki and forum, underline links to empty tags, and add tooltips to tags.",
      }),
      useRelativeTimestamps: Setting.Shared({
        help: 'Replace fixed times ("2016-08-10 23:25") with relative times ("3 months ago").',
      }),
      resizeableSidebars: Setting.Shared({
        help: "Make the tag sidebar resizeable (drag edge to resize).",
      }),
      autoplayVideos: Setting.Shared({
        help: "Enable autoplay for webm and mp4 posts (normally enabled by Danbooru).",
      }),
      loopVideos: Setting.Shared({
        help: "Enable looping for video_with_sound posts (normally disabled by Danbooru).",
      }),
      muteVideos: Setting.Shared({
        help: "Mute video_with_sound posts by default.",
        value: false,
      }),

      artistsRedesign: Setting.Shared({
        help: "Enable the redesigned /artists index.",
      }),
      commentsRedesign: Setting.Shared({
        help: "Enable comment scores and extra info on posts in /comments",
      }),
      postsRedesign: Setting.Shared({
        help: 'Move artist tags to the top of the tag list, put tag counts next to tag list headers, and add hotkeys for rating / voting on posts.',
      }),
      postVersionsRedesign: Setting.Shared({
        help: "Add thumbnails on the /post_versions page",
      }),
      wikiRedesign: Setting.Shared({
        help: "Make header sections in wiki entries collapsible and add table of contents to long wiki pages",
      }),
      usersRedesign: Setting.Shared({
        help: "Add expandable saved searches to user account pages",
      }),

      thumbnailPreviewDelay: Setting.Shared({
        configurable: false,
        help: "The delay in milliseconds when hovering over a thumbnail before the preview appears.",
        value: 650,
      }),

      tagScripts: Setting.Shared({
        configurable: false,
        value: _.fill(Array(10), "")
      }),

      defaultSidebarWidth: Setting.Session({
        value: 210
      }),
      defaultPreviewPanelWidth: Setting.Session({
        value: 785
      }),
      sidebarState: Setting.Session({
        value: {}
      }),
      previewPanelState: Setting.Session({
        value: {}
      }),
      modeMenuState: Setting.Session({
        value: {}
      }),
      tagScriptNumber: Setting.Session({
        value: 1
      }),
      headerFixed: Setting.Shared({
        value: true
      }),
    };
  }

  constructor() {
    this.migrate();

    if ($("#c-users #a-edit").length) {
      this.initializeForm();
    }
  }

  migrate() {
    if (Config.Items.schemaVersion.storage["EX.config.schemaVersion"] === undefined) {
      this.reset();
    }

    this.schemaVersion = Config.Items.schemaVersion.value;
  }

  get(key) {
    const item = Config.Items[key];
    const value = JSON.parse(_.defaultTo(
      item.storage["EX.config." + key],
      JSON.stringify(item.value)
    ));

    EX.debug(`[CFG] READ EX.config.${key}:`, value);
    return value;
  }

  set(key, value) {
    const item = Config.Items[key];
    item.storage["EX.config." + key] = JSON.stringify(value);
    EX.debug(`[CFG] SAVE EX.config.${key} =`, value);
    return this;
  }

  get all() {
    return _.mapValues(Config.Items, (v, k) => this.get(k));
  }

  reset() {
    _(Config.Items).each((item, key) => {
      EX.debug(`[CFG] DELETE EX.config.${key}`);
      delete item.storage["EX.config." + key];
    });

    return this;
  }

  pageKey() {
    const controller = $("#page > div:nth-child(2)").attr("id");
    const action = $("#page > div:nth-child(2) > div").attr("id");
    return `${controller} ${action}`;
  }

  initializeForm() {
    const settingsHtml =
      _(Config.Items)
      .map((props, name) => _.merge(props, { name }))
      .filter("configurable")
      .map(setting => this.renderOption(setting))
      .join("");

    $("#advanced-settings-section").after(`
      <fieldset id="ex-settings-section" style="/*display: none*/">
        ${settingsHtml}

        <div class="input">
            <label><a href="#" id="factory_reset">Factory Reset</a></label>
            <div class="hint">Clear all Danbooru EX data and reset settings to default state.</div>
        </div>
      </fieldset>
    `);

    $("#edit-options > a:nth-child(2)").after('| <a href="#ex-settings">EX Settings</a>');

    let $tabs = $("#edit-options a:not(#delete-account):not(#change-password)");
    $tabs.off("click").click(e => {
      $tabs.removeClass("active");
      $(e.target).addClass("active");

      $("#a-edit form fieldset").hide();
      $($(e.target).attr("href") + "-section").show();

      e.preventDefault();
    });

    $("#ex-settings-section input").change(e => {
      const name = $(e.target).attr("name");
      const value = e.target.checked;

      this.set(name, value);
      Danbooru.Utility.notice("Setting saved.");
    });

    $("#factory_reset").click(e => {
      confirm('Reset Danbooru EX settings?') && this.reset() && Danbooru.Utility.notice("Danbooru EX reset.");
    });
  }

  renderOption(setting) {
    const name = setting.name;
    const id = "ex_config_" + _.snakeCase(name);
    const value = this.get(name) ? "checked" : "";

    return `
      <div class="input checkbox optional">
        <input class="boolean optional" type="checkbox" ${value} name="${_.camelCase(name)}" id="${id}">
        <label class="boolean optional" for="${id}">${_.startCase(name)}</label>
        <div class="hint">${setting.help}</div>
      </div>
    `;
  }
}

// Define getters/setters for `Config.showHeaderBar` et al.
for (let k of _.keys(Config.Items)) {
  const key = k;
  Object.defineProperty(Config.prototype, key, {
    get: function ()  { return this.get(key) },
    set: function (v) { return this.set(key, v) },
    enumerable: true,
    configurable: true,
  });
}

class DText {
  static createExpandable(name, content) {
    const $expandable = $(`
      <div class="expandable">
        <div class="expandable-header">
          <span>${_.escape(name)}</span>
          <input type="button" value="Show" class="expandable-button">
        </div>
        <div class="expandable-content" style="display: none">
          ${content}
        </div>
      </div>
    `);

    // If our script runs before Danbooru's scripts do, Danbooru will find our
    // expandable and add it's own click handler on top of ours. So delay
    // adding our handler to make sure we overwrite Danbooru's own handler.
    $(function () {
      $expandable.find('.expandable-button').off("click").click(e => {
        $(e.target).closest('.expandable').find('.expandable-content').fadeToggle('fast');
        $(e.target).val((_$$1, val) => val === 'Show' ? 'Hide' : 'Show');
        e.preventDefault();
      });
    });

    return $expandable;
  }
}

class Resource {
  constructor(object) {
    Object.assign(this, object);
  }

  static async request(type, url, params = {}) {
    const query = `${url}?${decodeURIComponent($.param(params))}`;

    // console.time(`${type} ${query}`);
    const request = $.ajax({ url, type: "POST", data: Object.assign({}, params, { _method: type })});
    const response = await request;

    EX.debug(`[NET] ${request.status} ${request.statusText} ${query}`, request);

    if (Array.isArray(response)) {
      return response.map(r => new this(r));
    } else {
      return new this(response);
    }
  }

  static put(id, params = {}) {
    return this.request("PUT", `${this.controller}/${id}.json`, params);
  }

  static get(id, params = {}) {
    return this.request("GET", `${this.controller}/${id}.json`, params);
  }

  static index(params = {}) {
    return this.request("GET", `${this.controller}.json`, params);
  }

  static search(values, otherParams) {
    const key = this.primaryKey;
    const batchedValues = _(values).sortBy().sortedUniq().chunk(1000).value();

    const requests = batchedValues.map(batch => {
      const params = _.merge(this.searchParams, { search: otherParams }, { search: { [key]: batch.join(",") }});
      return this.index(params);
    });

    return Promise.all(requests).then(_.flatten);
  }

  static get searchParams() {
    return { limit: 1000 };
  }

  static get controller() {
    return "/" + _.snakeCase(this.name + "s");
  }
}

var Post = Resource.Post = class Post extends Resource {
  static get primaryKey() { return "post"; }

  get tags() {
    let split_tag_string = (tag_string, category) => {
      return tag_string.split(/\s+/).filter(String).map(name => ({ name, category }));
    };

    return _.concat(
      split_tag_string(this.tag_string_artist, 1),
      split_tag_string(this.tag_string_copyright, 3),
      split_tag_string(this.tag_string_character, 4),
      split_tag_string(this.tag_string_meta, 5),
      split_tag_string(this.tag_string_general, 0),
    );
  }

  static update(postId, tags) {
    return this.put(postId, { "post[old_tag_string]": "", "post[tag_string]": tags });
  }

  get source_domain() {
    try {
      const hostname = new URL(this.source).hostname;
      const domain = hostname.match(/([^.]*)\.([^.]*)$/)[0];
      return domain;
    } catch (_e) {
      return "";
    }
  }

  get source_link() {
    const maxLength = 10;
    const truncatedSource = this.source.replace(new RegExp(`(.{${maxLength}}).*$`), "$1...");

    if (this.source.match(/^https?:\/\//)) {
      return `<a href="${_.escape(this.source)}">${this.source_domain}</a>`;
    } else if (this.source.trim() !== "") {
      return `<i>${_.escape(truncatedSource)}</i>`;
    } else {
      return "<i>none</i>";
    }
  }

  get pretty_rating() {
    switch (this.rating) {
      case "s": return "Safe";
      case "q": return "Questionable";
      case "e": return "Explicit";
    }
  }
};

var Tag = Resource.Tag = class Tag extends Resource {
  static get Categories() {
    return [
      "General",    // 0
      "Artist",     // 1
      undefined,    // 2 (unused)
      "Copyright",  // 3
      "Character",  // 4
      "Meta"        // 5
    ];
  }

  static get searchParams() {
    return _.merge({}, super.searchParams, { search: { hide_empty: "no" }});
  }

  static get primaryKey() { return "name"; }

  static renderTag(tag) {
    const href = `/posts?tags=${encodeURIComponent(tag.name)}`;
    return `<a class="search-tag tag-type-${tag.category}" href="${href}">${_.escape(tag.name)}</a>`;
  }

  static renderTagList(tags, classes) {
    return `
      <section class="ex-tag-list ${classes}">
        <h1>Tags</h1>
        <ul>${tags.map(Tag.renderTagListItem).join("")}</ul>
      </section>
    `;
  }

  static renderTagListItem(tag) {
    return `<li class="category-${tag.category}">${Tag.renderTag(tag)}</li>`;
  }

  static renderSearchTagListItem(tag) {
    return `
      <li class="category-${tag.category}">
        <a class="wiki-link" href="/wiki_pages/show_or_new?title=${encodeURIComponent(tag.name)}">?</a>
        ${Tag.renderTag(tag)}
        <span class="post-count">${tag.post_count}</span>
      </li>
    `;
  }
};

var TagImplication = Resource.TagImplication = class TagImplication extends Resource {
  static get primaryKey() { return "id"; }
};

var User = Resource.User = class User extends Resource {
  static get primaryKey() { return "id"; }

  static render(user) {
    let classes = "user-" + user.level_string.toLowerCase();

    if (user.can_approve_posts) { classes += " user-post-approver"; }
    if (user.can_upload_free)   { classes += " user-post-uploader"; }
    if (user.is_super_voter)    { classes += " user-super-voter"; }
    if (user.is_banned)         { classes += " user-banned"; }
    if (Danbooru.Utility.meta("style-usernames") === "true") { classes += " with-style"; }

    return `
      <a class="${classes}" href="/users/${user.id}">${_.escape(user.name)}</a>
    `;
  }
};

class Posts {
  static initialize() {
    if ($("#c-posts #a-show").length === 0) {
      return;
    }

    $("#image").addClass("ex-fit-width");
    Posts.initializeResize();
    // Posts.initializeImplications();
    Posts.initializeTagList();
    Posts.initializeHotkeys();
    Posts.initializeVideo();
  }

  // Resize notes/ugoira controls as window is resized.
  static initializeResize() {
    new ResizeSensor($('#image-container'), () => {
      $("#image-resize-to-window-link").click();
    });
  }

  static initializeLargeThumbnails() {
    $("article.post-preview").each((i, e) => {
      const $post = $(e);
      const $img = $post.find("img");

      const data = Posts.normalize($post.data());
      const src = `${data.large_file_url}`;
      // const src = `//danbooru.s3.amazonaws.com/${data.md5}.${data.file_ext}`;

      const size = `${EX.config.largeThumbnailSize}px`;
      $post.css({ "width": size, "height": size });
      $img.css({ "width": size, "height": size, "object-fit": "contain" });
      $img.attr("src", src);
    });
  }

  static initializeTagList() {
    _.forOwn({
      "Artist": "artist",
      "Copyrights": "copyright",
      "Characters": "character",
      "Tags": "general",
      "Meta": "meta",
    }, (category, heading) => {
      let $header = $('#tag-list :header').filter((i, e) => $(e).text().match(heading));
      let $tags = $header.next('ul');

      $tags.addClass(`ex-${category}-tag-list`);
      $header.wrap(`<span class="ex-tag-list-header ex-${category}-tag-list-header">`);
      $header.parent().append(`<span class="post-count">${$tags.children().length}</span>`);
    });
  }

  static initializeImplications() {
    let $tags = $('#tag-list');
    let tag_string = $("#image-container").data("tags");

    TagImplication.index({ search: { antecedent_name: tag_string }}).then(implications => {
      let implied_tag_names = _(implications).map('descendant_names').flatMap(str => str.split(" ")).sort().uniq().value();

      Tag.search(implied_tag_names).then(implied_tags => {
        let sortCategory = (category) =>
            category === 1 ? 1  // artist
          : category === 2 ? 2  // copyright
          : category === 4 ? 3  // character
          : category === 0 ? 4  // general
          :                  0; // other

        implied_tags = _.sortBy(implied_tags, [(tag) => sortCategory(tag.category), "name"]);

        $tags.append(`
          <h2>Implied Tags</h2>
          <ul>
            ${implied_tags.map(Tag.renderSearchTagListItem).join("")}
          </ul>
        `);
      });
    });
  }

  /*
   * Alt+S: Rate Safe.
   * Alt+Q: Rate Questionable.
   * Alt+E: Rate Explicit.
   * U / Alt+U: Vote up / vote down.
   */
  static initializeHotkeys() {
    const post_id = Danbooru.Utility.meta("post-id");

    const rate = function (post_id, rating) {
      return function (e) {
        Danbooru.Post.update(post_id, {"post[rating]": rating});
        e.preventDefault();
      };
    };

    $(document).keydown("alt+s", rate(post_id, 's'));
    $(document).keydown("alt+q", rate(post_id, 'q'));
    $(document).keydown("alt+e", rate(post_id, 'e'));

    $(document).keydown("u",     () => Danbooru.Post.vote('up',   post_id));
    $(document).keydown("alt+u", () => Danbooru.Post.vote('down', post_id));
  }

  static initializeVideo() {
    const $video = $("video#image").get(0);
    if ($video) {
      $video.autoplay = EX.config.autoplayVideos;
      $video.muted = EX.config.muteVideos;
      $video.loop = EX.config.loopVideos;
    }
  }

  // Convert the object returned by $(post).data() to an object with the same
  // properties that the JSON API returns.
  static normalize(data) {
    let post = _.mapKeys(data, (v, k) => _.snakeCase(k));
    post.md5 = post.md_5;

    const flags = post.flags.split(/\s+/);
    post.is_pending = _.indexOf(flags, "pending") !== -1;
    post.is_flagged = _.indexOf(flags, "flagged") !== -1;
    post.is_deleted = _.indexOf(flags, "deleted") !== -1;

    post.has_visible_children = post.has_children;
    post.tag_string = post.tags;
    post.pool_string = post.pools;
    post.status_flags = post.flags;
    post.image_width = post.width;
    post.image_height = post.height;

    return post;
  }

  // Generate the post thumbnail HTML.
  static preview(post, { size="preview", classes=[] } = {}) {
    let preview_class = "post-preview ex-post-preview";
    preview_class += " " + classes.join(" ");

    if (size === "preview") {
        preview_class += post.is_pending           ? " post-status-pending"      : "";
        preview_class += post.is_flagged           ? " post-status-flagged"      : "";
        preview_class += post.is_deleted           ? " post-status-deleted"      : "";
        preview_class += post.parent_id            ? " post-status-has-parent"   : "";
        preview_class += post.has_visible_children ? " post-status-has-children" : "";
    }

    const data_attributes = `
      data-id="${post.id}"
      data-has-sound="${!!post.tag_string.match(/(video_with_sound|flash_with_sound)/)}"
      data-tags="${_.escape(post.tag_string)}"
      data-pools="${post.pool_string}"
      data-uploader="${_.escape(post.uploader_name)}"
      data-approver-id="${post.approver_id}"
      data-rating="${post.rating}"
      data-width="${post.image_width}"
      data-height="${post.image_height}"
      data-flags="${post.status_flags}"
      data-parent-id="${post.parent_id}"
      data-has-children="${post.has_children}"
      data-score="${post.score}"
      data-views="${post.view_count}"
      data-fav-count="${post.fav_count}"
      data-pixiv-id="${post.pixiv_id}"
      data-md5="${post.md5}"
      data-file-ext="${post.file_ext}"
      data-file-url="${post.file_url}"
      data-large-file-url="${post.large_file_url}"
      data-preview-file-url="${post.preview_file_url}"
    `;

    let src, scale;
    if (size === "preview") {
      src = post.preview_file_url;

      scale = Math.min(150 / post.image_width, 150 / post.image_height);
      scale = Math.min(1, scale);
    } else if (size === "large") {
      src = post.large_file_url;

      scale = Math.min(1, 850 / post.image_width);
    } else {
      src = post.file_url;

      scale = 1;
    }

    const [width, height] = [Math.round(post.image_width * scale), Math.round(post.image_height * scale)];

    let media;
    if (post.file_ext.match(/webm|mp4|zip/) && size != "preview") {
      const autoplay = (size === "large" || EX.config.autoplayVideos) ? "autoplay" : "";
      const loop     = (size === "large" || EX.config.loopVideos)     ? "loop"     : "";
      const muted    = (size === "large" || EX.config.muteVideos)     ? "muted"    : "";

      media = `
        <video ${autoplay} ${loop} ${muted} width="${width}" height="${height}"
               src="${src}" title="${_.escape(post.tag_string)}">
      `;
    } else {
      media = `
        <img itemprop="thumbnailUrl" width="${width}" height="${height}"
             src="${src}" title="${_.escape(post.tag_string)}">
      `;
    }

    // XXX get the tag params from the URL if on /posts.
    const tag_params = "";

    return `
      <article itemscope itemtype="http://schema.org/ImageObject"
               id="post_${post.id}" class="${preview_class}" ${data_attributes}>
        <a href="/posts/${post.id}${tag_params}">${media}</a>
      </article>
    `;
  }

  static renderExcerpt(post, uploader) {
    return `
      <section class="ex-excerpt ex-post-excerpt">
        <div class="ex-excerpt-title ex-post-excerpt-title">
          <span class="post-info id">
            Post #${post.id}
          </span>

          <span class="separator">·</span>
          <span class="post-info uploader">${User.render(uploader)}</span>

          <span class="separator">·</span>
          <time class="post-info created-at ex-short-relative-time"
                datetime="${post.created_at}"
                title="${moment(post.created_at).format()}">
            ${moment(post.created_at).locale("en-short").fromNow()} ago
          </time>

          <span class="separator">·</span>
          <span class="post-info up-score">
            ${post.up_score}
            <a href="#">
              <i class="fa fa-lg fa-thumbs-o-up" aria-hidden="true"></i>
            </a>
          </span>

          <span class="post-info down-score">
            ${post.down_score}
            <a href="#">
              <i class="fa fa-lg fa-thumbs-o-down" aria-hidden="true"></i>
            </a>
          </span>

          <span class="separator">·</span>
          <span class="post-info fav-count">
            <a href="#">${post.fav_count}</a>
            <a href="#">
              <i class="fa fa-lg fa-star-o" aria-hidden="true"></i>
            </a>
          </span>
        </div>
        <div class="ex-excerpt-body ex-post-excerpt-body">
          ${Posts.preview(post, { size: "large", classes: [ "ex-post-excerpt-preview", "ex-no-tooltip" ] })}
          <div class="ex-post-excerpt-metadata">
            ${Tag.renderTagList(post, "ex-tag-list-inline")}
          </div>
        </div>
      </section>
    `;
  }
}

class PreviewPanel {
  static initialize() {
    // This is the main content panel that comes before the preview panel.
    let $content = $(`
      #c-posts #content,
      #c-post-appeals #a-index,
      #c-post-flags #a-index,
      #c-post-versions #a-index,
      #c-explore-posts > div,
      #c-notes #a-index,
      #c-pools #a-gallery,
      #c-pools #a-show,
      #c-comments #a-index,
      #c-moderator-post-queues #a-show,
      #c-users #a-show,
      #c-wiki-pages > div > #content,
      #c-wiki-page-versions > div > #content
    `);

    if ($content.length === 0) {
      return;
    }

    $content.parent().addClass("ex-panel-container");
    $content.addClass("ex-content-panel ex-panel");
    $content.after(`
      <div id="ex-preview-panel-resizer" class="ex-vertical-resizer"></div>
      <section id="ex-preview-panel" class="ex-panel">
        <div id="ex-preview-panel-container">
          <article class="ex-no-image-selected">
            No image selected. Click a thumbnail to open image preview.
          </article>
        </div>
      </section>
    `);

    // XXX: sometimes a huge offset is calculated. don't know why.
    // PreviewPanel.origTop = $("#ex-preview-panel > div").offset().top;
    PreviewPanel.origTop = 127;

    const width = _.defaultTo(EX.config.previewPanelState[EX.config.pageKey()], EX.config.defaultPreviewPanelWidth);
    PreviewPanel.setWidth(width);
    PreviewPanel.save();

    if (ModeMenu.getMode() === "view") {
      PreviewPanel.$panel.hide();
    }

    $('.ex-mode-menu select[name="mode"]').change(PreviewPanel.switchMode);
    $("#ex-preview-panel-resizer").draggable({
      axis: "x",
      helper: "clone",
      drag: _.throttle(PreviewPanel.resize, 16),
      stop: _.debounce(PreviewPanel.save, 100),
    });
  }

  static async update($post) {
    const postId = $post.data("id");
    const post = await Post.get(postId);
    const html = PreviewPanel.renderPost(post);

    $("#ex-preview-panel > div").children().first().replaceWith(html);
  }

  static get $panel() {
    return $("#ex-preview-panel");
  }

  static resize(e, ui) {
    // XXX magic number
    PreviewPanel.setWidth($("body").innerWidth() - ui.position.left - 28);
  }

  static save() {
    let state = EX.config.previewPanelState;
    state[EX.config.pageKey()] = PreviewPanel.$panel.width();
    EX.config.previewPanelState = state;
  }

  static opened() {
    return PreviewPanel.$panel.is(":visible") && PreviewPanel.$panel.width() > 0;
  }

  static open() {
    if (PreviewPanel.$panel.width() === 0) {
      PreviewPanel.setWidth(EX.config.defaultPreviewPanelWidth);
    }

    PreviewPanel.$panel.show({ effect: "slide", direction: "left" }).promise().then(PreviewPanel.save);
  }

  static close() {
    PreviewPanel.$panel.hide({ effect: "slide", direction: "right" }).promise().then(PreviewPanel.save);
  }

  static switchMode() {
    if (ModeMenu.getMode() === "view") {
      PreviewPanel.close();
    } else {
      PreviewPanel.open();
    }
  }

  static setWidth(width) {
    PreviewPanel.$panel.width(width);
    PreviewPanel.$panel.css({ flex: `0 0 ${width}px` });
    $("#ex-preview-panel > div").width(width);
  }

  static renderPost(post) {
    return `
      <section class="ex-preview-panel-post">
        <div class="ex-preview-panel-post-metadata">
          <div class="ex-preview-panel-post-title">
            <span class="post-info">
              <h1>Score</h1>

              <span class="fav-count">
                ${post.fav_count}

                <a href="#">
                  <i class="far fa-heart" aria-hidden="true"></i>
                </a>
              </span>

              <span class="score">
                ${post.score}

                <a href="#">
                  <i class="far fa-thumbs-up" aria-hidden="true"></i>
                </a>
                <a href="#">
                  <i class="far fa-thumbs-down" aria-hidden="true"></i>
                </a>
              </span>
            </span>

            <span class="post-info uploader-name">
              <h1>User</h1>

              <a href="/users/${post.uploader_id}">${_.escape(post.uploader_name)}</a>

              <time class="created-at ex-short-relative-time" datetime="${post.created_at}" title="${moment(post.created_at).format()}">
                ${moment(post.created_at).locale("en-short").fromNow()}
              </time>
            </span>

            <span class="post-info rating">
              <h1>Rating</h1>
              ${post.rating.toUpperCase()}
            </span>

            <span class="post-info source">
              <h1>Source</h1>
              ${post.source_link}
            </span>

            <span class="post-info dimensions">
              <h1>Size</h1>
              ${post.image_width}x${post.image_height}
            </span>
          </div>

          <div class="ex-preview-panel-post-tags">
            ${Tag.renderTagList(post.tags, "ex-tag-list-inline")}
          </div>
        </div>

        <div class="ex-preview-panel-post-body">
          ${Posts.preview(post, { size: "large", classes: [ "ex-preview-panel-image" ] })}
        </div>
      </section>
    `;
  }
}

class Navigation {
  static gotoPageN(n) {
    if (location.search.match(/page=(\d+)/)) {
      location.search = location.search.replace(/page=(\d+)/, `page=${n}`);
    } else {
      location.search += `&page=${n}`;
    }
  }

  static gotoPage(event) {
    Navigation.gotoPageN(Number(event.key));
  }

  static gotoLastPage(event) {
    // a:not(a[rel]) - exclude the Previous/Next links seen in the paginator on /favorites et al.
    const n = $('div.paginator li:nth-last-child(2) a:not(a[rel])').first().text();

    if (n) {
      Navigation.gotoPageN(n);
    }
  }

  static gotoPageDialog() {
    const $dialog = $(`
      <form>
        <input id="ex-dialog-input" type="text" placeholder="Enter page number">
        <input type="submit" value="Go">
      </form>
    `).dialog({
      title: "Go To Page",
      minHeight: 0,
      minWidth: 0,
      resizable: false,
      modal: true,
    });

    $dialog.submit(() => {
      const page = $dialog.find('input[type="text"]').val();
      Navigation.gotoPageN(page);
      return false;
    });

    return false;
  }

  static goDirection(direction) {
    var href = $(`.paginator a[rel=${direction}]`).attr("href");
    if (href) {
      window.location = href;
    }
  }

  static goTop()    { window.scrollTo(0, 0); }
  static goBottom() { window.scrollTo(0, $(document).height()); }
  static goForward() { window.history.forward(); }
  static goBack()    { window.history.back(); }
  static goNext()   { Navigation.goDirection("next"); }
  static goPrev()   { Navigation.goDirection("prev"); }

  static scroll(direction, duration, distance) {
    return _.throttle(() => {
      const top = $(window).scrollTop() + direction * $(window).height() * distance;
      $('html, body').animate({scrollTop: top}, duration, "linear");
    }, duration);
  }
}

class ModeMenu {
  static initialize() {
    ModeMenu.uninitializeDanbooruModeMenu();
    ModeMenu.overrideDanbooruArrowKeys();
    ModeMenu.initializeModeMenu();
    ModeMenu.initializeTagScriptControls();
    ModeMenu.initializeThumbnails();
  }

  static uninitializeDanbooruModeMenu() {
    Danbooru.PostModeMenu.initialize = _.noop;
    Danbooru.PostModeMenu.show_notice = _.noop;
    $(".post-preview a").unbind("click", Danbooru.PostModeMenu.click);
    $(document).unbind("keydown", "1 2 3 4 5 6 7 8 9 0", Danbooru.PostModeMenu.change_tag_script);
    $("#sidebar #mode-box").hide();
  }

  // Danbooru's default left / right arrow key bindings conflict with our use
  // of the arrow keys in tag script / preview mode. Ignore these bindings
  // during these modes.
  static overrideDanbooruArrowKeys() {
    $('[data-shortcut="d right"]').attr("data-shortcut", "d");
    $('[data-shortcut="a left"]').attr("data-shortcut", "a");
    Danbooru.Shortcuts.initialize_data_shortcuts();

    Danbooru.Utility.keydown("left",  "keydown.danbooru.arrow_prev_page", _e => ModeMenu.getMode() === "view" && Navigation.goPrev());
    Danbooru.Utility.keydown("right", "keydown.danbooru.arrow_next_page", _e => ModeMenu.getMode() === "view" && Navigation.goNext());
  }

  static initializeModeMenu() {
    $('.ex-mode-menu select[name="mode"]').change(ModeMenu.switchMode);
    const defaultMode = (EX.config.defaultPreviewMode && EX.config.pageKey() === "c-posts a-index") ? "preview" : "view";
    const mode = _.defaultTo(EX.config.modeMenuState[EX.config.pageKey()], defaultMode);
    ModeMenu.setMode(mode);
  }

  static initializeTagScriptControls() {
    $('.ex-mode-menu input[name="tag-script"]').on(
      "input", _.debounce(ModeMenu.saveTagScript, 250)
    );

    $('.ex-mode-menu select[name="tag-script-number"]').change(ModeMenu.switchTagScript);
    ModeMenu.setTagScriptNumber(EX.config.tagScriptNumber);

    $('.ex-mode-menu button[name="apply"]').click(ModeMenu.applyTagScript);
    $('.ex-mode-menu button[name="select-all"]').click(ModeMenu.selectAll);
    $('.ex-mode-menu button[name="select-invert"]').click(ModeMenu.invertSelection);
  }

  static initializeThumbnails() {
    const selector = `
      .mod-queue-preview aside a,
      div.post-preview .preview a,
      article.post-preview:not(.ex-preview-panel-image) a
    `;

    $(document).on("click", selector, ModeMenu.onThumbnailClick);

    // Hide cursor when clicking outside of thumbnails.
    $(document).on("click", () => $(".ex-cursor").removeClass("ex-cursor"));
  }

  static switchToTagScript(event) {
    const newN = Number(String.fromCharCode(event.which));
    const oldN = ModeMenu.getTagScriptNumber();

    if (ModeMenu.getMode() === "tag-script" && newN === oldN) {
      $('.ex-mode-menu input[name="tag-script"]').select();
    } else {
      ModeMenu.setMode("tag-script");
      ModeMenu.setTagScriptNumber(newN);
    }

    event.preventDefault();
  }

  static switchMode() {
    const mode = ModeMenu.getMode();

    let state = EX.config.modeMenuState;
    state[EX.config.pageKey()] = mode;
    EX.config.modeMenuState = state;

    $("body").removeClass((i, klass) => (klass.match(/mode-.*/) || []).join(' '));
    $("body").addClass(`mode-${mode}`);

    if (mode === "tag-script") {
      $(".ex-mode-menu .ex-tag-script-controls").show();

      $("#page").selectable({
        filter: "article.post-preview, div.post-preview .preview, .mod-queue-preview aside",
        delay: 200,
      });
    } else {
      $(".ex-mode-menu .ex-tag-script-controls").hide();

      if ($("#page").selectable("instance")) {
        $("#page").selectable("destroy");
      }
    }
  }

  static switchTagScript(event) {
    const n = ModeMenu.getTagScriptNumber();
    EX.config.tagScriptNumber = n;

    const script = EX.config.tagScripts[n];
    $('.ex-mode-menu input[name="tag-script"]').val(script).change();
  }

  static onThumbnailClick(event) {
    // Only apply on left click, not middle click and not ctrl+left click.
    if (event.ctrlKey || event.which !== 1) {
      return true;
    }

    // XXX prevent focused text fields from staying focused when clicking on thumbnails.
    $(":focus").blur();

    if (ModeMenu.getMode() === "view") {
      return true;
    } else {
      Selection.moveCursorTo($(event.target), { selectTarget: true, selectInterval: event.shiftKey });
      return false;
    }
  }

  static applyTagScript(event) {
    const mode = ModeMenu.getMode();

    if (mode === "tag-script") {
      const tags = ModeMenu.getTagScript();
      const postIds = $(".ui-selected").map((i, e) => $(e).closest(".post-preview").data("id"));

      ModeMenu.updatePosts(postIds, tags);
    }
  }

  static updatePosts(postIds, tags, updated = 0, total = postIds.length) {
    const requests = _.map(postIds, postId => {
      const promise = Promise.resolve(Post.update(postId, tags));

      return promise.then(post => {
          updated++;
          Danbooru.Utility.notice(`Updated post #${postId} (${total - updated} remaining)`);
          return { post: post, status: 200 };
        }).catch(resp => {
          return { id: postId, status: resp.status };
        });
    });

    Promise.all(requests).then(posts => {
      const failedPosts = _(posts).difference(_.filter(posts, { status: 200 })).map("id").value();
      const delay = Math.min((failedPosts.length / 4), 3);

      if (failedPosts.length > 0) {
        _.delay(() => ModeMenu.updatePosts(failedPosts, tags, updated, total), delay * 1000);
      }
    });
  }

  static selectAll(event) {
    if ($(".ui-selected").length) {
      $(".ui-selected").removeClass("ui-selected");
    } else {
      $(".ui-selectee").addClass("ui-selected");
    }

    event.preventDefault();
  }

  static invertSelection(event) {
    let $unselected = $(".ui-selectee:not(.ui-selected)");
    let $selected = $(".ui-selectee.ui-selected");

    $unselected.addClass("ui-selected");
    $selected.removeClass("ui-selected");
  }

  static getMode() {
    return $(".ex-mode-menu select").val();
  }

  static setMode(mode) {
    $('.ex-mode-menu select[name="mode"]').val(mode).change();
  }

  static toggleMode(mode) {
    ModeMenu.setMode(ModeMenu.getMode() === mode ? "view" : mode);
  }

  static getTagScript() {
    return $('.ex-mode-menu input[name="tag-script"]').val().trim();
  }

  static saveTagScript() {
    const scripts = EX.config.tagScripts;
    scripts[ModeMenu.getTagScriptNumber()] = ModeMenu.getTagScript();
    EX.config.tagScripts = scripts;
  }

  static getTagScriptNumber() {
    return Number($('.ex-mode-menu select[name="tag-script-number"]').val());
  }

  static setTagScriptNumber(n) {
    $('.ex-mode-menu select[name="tag-script-number"]').val(n).change();
  }
}

class Selection {
  static get post() {
    return "article.post-preview, div.post-preview .preview, .mod-queue-preview aside";
  }

  static get $cursor() {
    return Selection.active()
         ? $(".ex-cursor")
         : $(Selection.post).first().addClass("ex-cursor");
  }

  static set $cursor($newCursor) {
    Selection.$cursor.removeClass("ex-cursor");
    return $newCursor.addClass("ex-cursor");
  }

  static active() {
    return $(".ex-cursor").length > 0;
  }

  static between($from, $to) {
    if ($from.nextAll().is($to)) {
      return $from.nextUntil($to, Selection.post).add($to).addBack();
    } else if ($from.prevAll().is($to)) {
      return $from.prevUntil($to, Selection.post).add($to).addBack();
    } else {
      return $();
    }
  }

  static selectBetween($from, $to) {
    return Selection.between($from, $to).addClass("ui-selected");
  }

  static deselectBetween($from, $to) {
    return Selection.between($from, $to).removeClass("ui-selected");
  }

  static moveCursor(direction, { selectInterval = false } = {}) {
    // XXX if ($(Selection.post).length === 0) {
    if (ModeMenu.getMode() === "view") {
      return true;
    }

    const post = Selection.post;
    const $cursor = Selection.$cursor;
    const firstInColumn = $posts =>
      $posts.filter((i, e) => $(e).position().left === $cursor.position().left).first();

    const $target = direction === "left"  ? $cursor.prev(post)
                  : direction === "right" ? $cursor.next(post)
                  : direction === "up"    ? firstInColumn($cursor.prevAll(post))
                  : direction === "down"  ? firstInColumn($cursor.nextAll(post))
                  : $();

    // XXX cleanup
    if ($target.length) {
      if (selectInterval) {
        $cursor.closest(Selection.post).toggleClass("ui-selected");
      }

      Selection.moveCursorTo($target, { selectInterval });
    }
  }

  static moveCursorTo($target, { selectTarget = false, selectInterval = false } = {}) {
    const $newCursor = $target.closest(Selection.post);
    const $oldCursor = $(".ex-cursor").length
                     ? $(".ex-cursor")
                     : $newCursor;

    const $newMark = $newCursor;
    const $oldMark = $(".ex-mark").length
                   ? $(".ex-mark")
                   : $(Selection.post).first().addClass("ex-mark");

    Selection.swapCursor($oldCursor, $newCursor);

    if (selectTarget) {
        $newCursor.toggleClass("ui-selected");
    }

    if (selectInterval) {
        Selection.deselectBetween($oldMark, $oldCursor);
        Selection.selectBetween($oldMark, $newCursor);
    } else {
        $oldMark.removeClass("ex-mark");
        $newMark.addClass("ex-mark");
    }
  }

  static swapCursor($oldCursor, $newCursor) {
    const $post = $newCursor.closest(".post-preview");

    $oldCursor.removeClass("ex-cursor");
    $newCursor.addClass("ex-cursor");

    Selection.scrollWindowTo($newCursor);
    $newCursor.find("a").focus();

    PreviewPanel.update($post);
  }

  static scrollWindowTo($target) {
    const targetTop = $target.position().top;
    const targetHeight = $target.height();

    if (targetTop + targetHeight > window.scrollY + window.innerHeight) {
      window.scrollTo(0, targetTop + 2*targetHeight - window.innerHeight);
    } else if (targetTop < window.scrollY) {
      window.scrollTo(0, targetTop - targetHeight);
    }
  }

  static toggleSelected() {
    if (!Selection.active()) { return true; }
    Selection.$cursor.toggleClass("ui-selected");
  }

  static open() {
    if (!Selection.active()) { return true; }
    window.location = Selection.$cursor.find("a").attr("href");
  }

  static openInNewTab() {
    if (!Selection.active()) { return true; }
    window.open(Selection.$cursor.find("a").attr("href"));
  }

  static favorite() {
    if (!Selection.active()) { return true; }

    const post = Posts.normalize(Selection.$cursor.closest(".post-preview").data());

    $.post("/favorites.json", { post_id: post.id }).then(() =>
      Danbooru.Utility.notice(`You have favorited post #${post.id}.`)
    );
  }
}

class Header {
  static initialize() {
    Header.initializeHeader();

    if (EX.config.enableModeMenu) {
      Header.initializeModeMenu();

      if (EX.config.enablePreviewPanel) {
        PreviewPanel.initialize();
      }
    }
  }

  static initializeHeader() {
    let $header = $(Header.render()).insertBefore("#top");
    _.defer(() => $header.show());

    // Move news announcements inside of EX header.
    $("#news-updates").insertBefore("#ex-header .ex-header-wrapper");

    // Initalize header search box.
    Header.$tags.val($("#sidebar #tags").val());
    Danbooru.Autocomplete && Danbooru.Autocomplete.initialize_all && Danbooru.Autocomplete.initialize_all();

    Header.$close.click(Header.toggle);
    $(document).scroll(_.throttle(Header.onScroll, 16));
  }

  static initializeModeMenu() {
    $(".ex-mode-menu").show();
    ModeMenu.initialize();
  }

  static onScroll() {
    $("#ex-header").toggleClass("ex-header-scrolled", window.scrollY > 0, { duration: 100 });
    // Shrink header after scrolling down.
    window.scrollY > 0 && $("header h1").addClass("ex-small-header");
  }

  static executeSearchInNewTab() {
    // XXX
    if ($("#ex-header #ex-tags:focus").length) {
      const tags = Header.$tags.val().trim();
      window.open(`/posts?tags=${encodeURIComponent(tags)}`, "_blank").focus();
    }
  }

  static focusSearch() {
    // Add a space to end if box is non-empty and doesn't already have trailing space.
    Header.$tags.val().length && Header.$tags.val((i, v) => v.replace(/\s*$/, ' '));
    Header.$tags.focus();
    return false;
  }

  static toggle() {
    Header.$el.toggleClass("ex-fixed ex-static");
    Header.$close.find("i").toggleClass("fa-times-circle fa-thumbtack");
    EX.config.headerFixed = !EX.config.headerFixed;
  }

  static get $el()    { return $("#ex-header"); }
  static get $close() { return $("#ex-header .ex-header-close"); }
  static get $tags()  { return $("#ex-header #ex-tags"); }

  static render() {
    return `
      <header style="display: none;" id="ex-header" class="${EX.config.headerFixed ? "ex-fixed" : "ex-static"}">
        <div class="ex-header-wrapper">
          <h1 class="ex-small-header"><a href="/">Danbooru</a></h1>

          <form class="ex-search-box" action="/posts" accept-charset="UTF-8" method="get">
            <input type="text" data-autocomplete="tag-query" name="tags" id="ex-tags" class="ui-autocomplete-input" autocomplete="off">
            <input type="submit" value="Go">
          </form>

          <section class="ex-mode-menu" style="display: none">
            <label for="mode">Mode</label>
            <select name="mode">
              <option value="view">View</option>
              <option value="preview">Preview</option>
              <option value="tag-script">Tag script</option>
            </select>

            <fieldset class="ex-tag-script-controls" style="display: none">
              <select name="tag-script-number">
                <option value="1">1</option>
                <option value="2">2</option>
                <option value="3">3</option>
                <option value="4">4</option>
                <option value="5">5</option>
                <option value="6">6</option>
                <option value="7">7</option>
                <option value="8">8</option>
                <option value="9">9</option>
              </select>

              <input id="${EX.config.enableModeMenu ? "tag-script-field" : "" }" name="tag-script" type="text" data-autocomplete="tag-query" placeholder="Enter tag script">
              <button name="apply" type="button">Apply</button>

              <label>Select</label>
              <button name="select-all" type="button">All/None</button>
              <button name="select-invert" type="button">Invert</button>
            </fieldset>
          </section>

          <a class="ex-header-close">
            <i class="fas fa-lg ${EX.config.headerFixed ? "fa-times-circle" : "fa-thumbtack"}" aria-hidden="true"></i>
          </a>
        </div>
      </header>
    `;
  }
}

class Keys {
  constructor() {
    this.actions = {};
    this.bindings = [];
  }

  register(actions = {}) {
    this.actions = _.merge({}, this.actions, actions);
    return this;
  }

  bind(bindings) {
    _(bindings).each(binding => {
      const keys = _(binding).keys().get(0);
      const action = binding[keys];
      const callback = this.actions[action];

      if (action === undefined || callback === undefined) {
        EX.debug(`[KEY] FAIL ${keys} -> ${action}`);
        return this;
      }

      Mousetrap.bind(keys, event => {
        EX.debug(`[KEY] EXEC ${keys} -> ${action} (${callback.name})`);

        return callback(event) === true;
      });

      EX.debug(`[KEY] BIND ${keys} -> ${action} (${callback.name})`);
    });

    this.bindings = _.concat(this.bindings, bindings);
    return this;
  }

  initialize() {
    // Numpad 5
    Mousetrap.addKeycodes({ 12: "clear" });

    this.register({
      "escape": Keys.escape,

      "goto-page": Navigation.gotoPage,
      "goto-last-page": Navigation.gotoLastPage,
      "goto-page-dialog": Navigation.gotoPageDialog,

      "go-my-account": () => window.location = `/users/${Danbooru.Utility.meta("current-user-id")}`,
      "go-my-dmails": () => window.location = "/dmails",
      "go-my-favorites": () => window.location = `/posts?tags=ordfav:${encodeURIComponent(Danbooru.Utility.meta("current-user-name"))}`,
      "go-my-saved-searches": () => window.location = `/saved_searches`,
      "go-my-settings": () => window.location = `/users/${Danbooru.Utility.meta("current-user-id")}/edit`,

      "go-artists-index": () => window.location = "/artists",
      "go-bur-new": () => window.location = "/bulk_update_requests/new",
      "go-comments-index": () => window.location = "/comments",
      "go-forum-index": () => window.location = "/forum_topics",
      "go-pools-index": () => window.location = "/pools",
      "go-post-index": () => window.location = "/posts",
      "go-wiki-index": () => window.location = "/wiki_pages",

      "go-top": Navigation.goTop,
      "go-bottom": Navigation.goBottom,
      "go-forward": Navigation.goForward,
      "go-back": Navigation.goBack,

      "header-toggle": Header.toggle,
      "header-focus-search": Header.focusSearch,
      "header-execute-search-in-new-tab": Header.executeSearchInNewTab,

      "select-all": ModeMenu.selectAll,
      "invert-selection": ModeMenu.invertSelection,
      "apply-tag-script": ModeMenu.applyTagScript,
      "switch-to-tag-script": ModeMenu.switchToTagScript,
      "set-preview-mode": () => ModeMenu.setMode("preview"),

      "move-cursor-up": e => Selection.moveCursor("up", { selectInterval: e.shiftKey }),
      "move-cursor-right": e => Selection.moveCursor("right", { selectInterval: e.shiftKey }),
      "move-cursor-down": e => Selection.moveCursor("down", { selectInterval: e.shiftKey }),
      "move-cursor-left": e => Selection.moveCursor("left", { selectInterval: e.shiftKey }),

      "cursor-open": Selection.open,
      "cursor-open-in-new-tab": Selection.openInNewTab,
      "cursor-toggle-selected": Selection.toggleSelected,

      "cursor-favorite": Selection.favorite,

      "save-search": () => $("#save-search").click(),
    });

    this.bind([
      { "q": "header-focus-search" },
      { "h t": "header-toggle" },
      { "h h": "header-focus-search" },

      { "S": "save-search" },

      { "g :": "goto-page-dialog" },
      { "g 0": "goto-last-page" },
      { "g 1": "goto-page" },
      { "g 2": "goto-page" },
      { "g 3": "goto-page" },
      { "g 4": "goto-page" },
      { "g 5": "goto-page" },
      { "g 6": "goto-page" },
      { "g 7": "goto-page" },
      { "g 8": "goto-page" },
      { "g 9": "goto-page" },

      { "g g": "go-top" },
      { "G":   "go-bottom" },
      { "g f": "go-forward" },
      { "g b": "go-back" },

      { "g h": "go-my-account" },
      { "g d": "go-my-dmails" },
      { "g F": "go-my-favorites" },
      { "g s": "go-my-settings" },
      { "g S": "go-my-saved-searches" },

      { "g a": "go-artists-index" },
      { "g B": "go-bur-new" },
      { "g c": "go-comments-index" },
      { "g f": "go-forum-index" },
      { "g P": "go-pools-index" },
      { "g p": "go-post-index" },
      { "g w": "go-wiki-index" },

      { "1": "switch-to-tag-script" },
      { "2": "switch-to-tag-script" },
      { "3": "switch-to-tag-script" },
      { "4": "switch-to-tag-script" },
      { "5": "switch-to-tag-script" },
      { "6": "switch-to-tag-script" },
      { "7": "switch-to-tag-script" },
      { "8": "switch-to-tag-script" },
      { "9": "switch-to-tag-script" },

      { "shift+a": "apply-tag-script" },
      { "ctrl+a": "select-all" },
      { "ctrl+i": "invert-selection" },
      { "`": "set-preview-mode" },
      { "~": "set-preview-mode" },

      { "up": "move-cursor-up" },
      { "right": "move-cursor-right" },
      { "down": "move-cursor-down" },
      { "left": "move-cursor-left" },

      { "shift+up": "move-cursor-up" },
      { "shift+right": "move-cursor-right" },
      { "shift+down": "move-cursor-down" },
      { "shift+left": "move-cursor-left" },

      { "return": "cursor-open" },
      { "ctrl+return": "cursor-open-in-new-tab" },
      { "space": "cursor-toggle-selected" },

      { "clear": "cursor-toggle-selected" }, // Numpad 5
      { "del": "apply-tag-script" }, // Numpad Period
      { "*": "select-all" }, // Numpad Multiply

      { "f": "cursor-favorite" },
    ]);

    // XXX don't hardcode these
    Mousetrap.bindGlobal("esc", Keys.escape);
    Mousetrap.bindGlobal("ctrl+return", Keys.submitForm);

    // XXX figure out how to unbind W/S properly.
    //$(document).unbind("keydown", "w s");
    //Mousetrap.bind("w", Keys.scroll(+1, 50, 0.06));
    //Mousetrap.bind("s", Keys.scroll(-1, 50, 0.06));
    // Danbooru.Shortcuts.nav_scroll_down = Navigation.scroll(+1, 50, 0.06);
    // Danbooru.Shortcuts.nav_scroll_up   = Navigation.scroll(-1, 50, 0.06);
  }

  /* Actions */

  static escape(event) {
    const $target = $(event.target);

    if ($target.is("input, textarea")) {
      $target.blur();
    } else {
      $('#close-notice-link').click();
      // XXX only do if no notice and not already in view mode.
      ModeMenu.setMode("view");
    }

    // Allow event to bubble up so that escape still closes jquery UI dialogs.
    return true;
  }

  static submitForm(event) {
    const $target = $(event.target);

    if ($target.is("#ex-tags:focus")) {
      Header.executeSearchInNewTab();
    } else if ($target.is("input, textarea")) {
      $target.closest("form").find('input[type="submit"][value="Submit"]').click();
    }

    return false;
  }
}

class Notes {
  static initialize() {
    $(Notes.initializeLivePreview);
  }

  static initializeLivePreview() {
    Danbooru.Note.Edit.show = _.wrap(Danbooru.Note.Edit.show, (show, ...args) => {
      show(...args);

      $(".note-edit-dialog textarea").off("input").on("input", _.throttle(Notes.updatePreview, 32));
    });
  }

  static updatePreview(event) {
    const $textarea = $(event.target);
    const note_id = $textarea.closest(".ui-dialog-content").data("id");
    const $note_body = Danbooru.Note.Body.find(note_id);
    const $note_box = Danbooru.Note.Box.find(note_id);

    Danbooru.Note.Body.set_text($note_body, $note_box, $textarea.val());
    Danbooru.Note.Body.show(note_id);
  }
}

class PostPreviews {
  // Show post previews when hovering over post #1234 links.
  static initializePostLinkPreviews() {
    const posts = $('a[href^="/posts/"]').filter((i, e) => /post #\d+/.test($(e).text()));
    PostPreviews.initialize(posts);
  }

  // Show post previews when hovering over thumbnails.
  static initializeThumbnailPreviews() {
    // The thumbnail container is .post-preview on every page but comments and
    // the mod queue. Handle those specially.
    const posts = `
      .post-preview:not(.ex-no-tooltip) > a > img,
      #c-comments .post-preview > .preview > a > img,
      #c-post-moderator-queues .mod-queue-preview aside img
    `;

    PostPreviews.initialize(posts);
  }

  static initialize(selector) {
    $(document).on('mouseover', selector, event => {
      const delay = EX.config.thumbnailPreviewDelay;
      const [, postID] = $(event.target).closest("a").attr("href").match(/\/posts\/(\d+)/);

      $(event.target).qtip({
        content: {
          text: (event, api) => {
            Post.get(postID).then(post => {
              User.get(post.uploader_id).then(uploader => {
                api.set("content.text", Posts.renderExcerpt(post, uploader));
                api.reposition(event, false);
              });
            });

            return "Loading...";
          }
        },
        events: {
          show: (event) => {
            if (PreviewPanel.opened()) {
              event.preventDefault();
            }
          }
        },
        overwrite: false,
        style: {
          classes: "qtip-bootstrap",
          tip: {
            corner: false,
          }
        },
        show: {
          delay: delay,
          solo: true,
          event: event.type,
          ready: true
        },
        hide: {
          delay: 100,
          fixed: true,
        },
        position: {
          my: "top left",
          at: "top right",
          viewport: $("#ex-viewport"),
          effect: false,
          adjust: {
            method: "flipinvert shift",
            resize: false,
            scroll: false,
            x: 10,
          }
        }
      }, event);
    });
  }
}

class Sidebar {
  static initialize() {
    let $sidebar = Sidebar.$panel;

    if ($sidebar.length === 0) {
      return;
    }

    $sidebar.parent().addClass("ex-panel-container");
    $sidebar.addClass("ex-panel");

    const width = _.defaultTo(EX.config.sidebarState[EX.config.pageKey()], EX.config.defaultSidebarWidth);
    Sidebar.width = width;

    $sidebar.after(`
      <div id="ex-sidebar-resizer" class="ex-vertical-resizer"></div>
    `);

    $("#ex-sidebar-resizer").draggable({
      axis: "x",
      helper: "clone",
      drag: _.throttle(Sidebar.resize, 16),
      stop: _.debounce(Sidebar.save, 100),
    });
  }

  static resize(event, ui) {
    const width = Math.max(0, ui.position.left - Sidebar.$panel.position().left);
    Sidebar.width = width;
  }

  static open() {
    if (Sidebar.width === 0) {
      Sidebar.width = EX.config.defaultSidebarWidth;
    }

    Sidebar.$panel.show({ effect: "slide", direction: "right" }).promise().then(Sidebar.save);
  }

  static close() {
    Sidebar.$panel.hide({ effect: "slide", direction: "left" }).promise().then(Sidebar.save);
  }

  static save() {
    let state = EX.config.sidebarState;
    state[EX.config.pageKey()] = Math.max(0, Sidebar.width);
    EX.config.sidebarState = state;
  }

  static get $panel() {
    return $("#sidebar");
  }

  static get width() {
    return Sidebar.$panel.width();
  }

  static set width(width) {
    Sidebar.$panel.width(width);
    Sidebar.$panel.toggle(Sidebar.$panel.width() > 0);
  }
}

var Artist = Resource.Artist = class Artist extends Resource {
  static get primaryKey() { return "id"; }
};

class Artists {
  static initialize() {
    if ($("#c-artists #a-index").length) {
      Artists.replaceIndex();
    }
  }

  static replaceIndex() {
    let $table = $("#c-artists #a-index > table:nth-child(2)");

    let artists = _($table.find("> tbody > tr")).map(e => ({
      id:   $(e).attr("id").match(/artist-(\d+)/)[1],
      name: $(e).find("> td:nth-child(1) > a:nth-child(1)").text()
    }));

    let requests = [
      Artist.search(artists.map("id"), { order: UI.query("search[order]") }),
      Tag.search(artists.map("name"), { hide_empty: "no" }),
      Artist.index({ search: { is_active: true, order: "created_at" }, limit: 8 }),
      Artist.index({ search: { is_active: true, order: "updated_at" }, limit: 8 }),
      Artist.index({ search: { is_active: false, order: "updated_at" }, limit: 8 }),
    ];

    Promise.all(requests).then(([artists, tags, created, updated, deleted]) => {
      artists = artists.map(artist =>
        _.merge(artist, {
          tag: _(tags).find(["name", artist.name])
        })
      );

      let $paginator = $(".paginator");

      const index = Artists.renderIndex(artists, created, updated, deleted);
      $("#c-artists #a-index").addClass("ex-index").html(index);

      $paginator.appendTo("#content");
    });
  }

  static renderIndex(artists, created, updated, deleted) {
    return `
    <aside id="sidebar">
      ${Artists.renderSidebar(created, updated, deleted)}
    </aside>

    <section id="content">
      ${Artists.renderTable(artists)}
    </section>
    `;
  }

  static renderSidebar(created, updated, deleted) {
    return `
    <section class="ex-artists-search">
      ${Artists.renderSearchForm()}
    </section>

    <section class="ex-artists-recent-changes">
      ${Artists.renderRecentChanges(created, updated, deleted)}
    </section>
    `;
  }

  static renderSearchForm() {
    return `
    <h1>Search</h1>

    <form class="simple_form" action="/artists" accept-charset="UTF-8" method="get">
      <input name="utf8" type="hidden" value="✓">

      <label for="search_name">Name</label>
      <input type="text" name="search[name]"
            id="search_name" class="ui-autocomplete-input" autocomplete="off"
            placeholder="Search artist name or URL">

      <label for="search_order">Order</label>
      <select name="search[order]" id="search_order">
        <option value="created_at">Recently created</option>
        <option value="updated_at">Last updated</option>
        <option value="name">Name</option>
      </select>

      <input type="submit" name="commit" value="Search">
    </form>
    `;
  }

  static renderRecentChanges(created, updated, deleted) {
    function renderArtistsList(artists, heading, params) {
      return `
      <section class="ex-artists-list">
        <div class="ex-artists-list-heading">
          <h2>${heading}</h2>
          <span>
            (${UI.linkTo("more", "/artists", { search: params })})
          </span>
        </div>
        <ul>
          ${renderUl(artists)}
        </ul>
      </section>
      `;
    }

    function renderUl(artists) {
      return _(artists).map(artist => `
        <li class="category-1">
          ${UI.linkTo(artist.name, `/artists/${artist.id}`)}

	  <time class="ex-short-relative-time"
                datetime="${artist.updated_at}"
                title="${moment(artist.updated_at).format()}">
            ${moment(artist.updated_at).locale("en-short").fromNow()}
          </time>
        </li>
      `).join("");
    }

    return `
    <h1>Recent Changes</h1>

    ${renderArtistsList(created, "New Artists",     { is_active: true,  order: "created_at" })}
    ${renderArtistsList(updated, "Updated Artists", { is_active: true,  order: "updated_at" })}
    ${renderArtistsList(deleted, "Deleted Artists", { is_active: false, order: "updated_at" })}
    `;
  }

  static renderTable(artists) {
    return `
    <table class="ex-artists striped" width="100%">
      <thead>
        <tr>
          <th class="ex-artist-id">ID</th>
          <th class="ex-artist-name">Name</th>
          <th class="ex-artist-post-count">Posts</th>
          <th class="ex-artist-other-names">Other Names</th>
          <th class="ex-artist-group-name">Group</th>
          <th class="ex-artist-status">Status</th>
          <th class="ex-artist-created">Created</th>
          <th class="ex-artist-updated">Updated</th>
        </tr>
      </thead>
      <tbody>
        ${artists.map(Artists.renderRow).join("")}
      </tbody>
    </table>
    `;
  }

  static renderRow(artist) {
    const otherNames =
      (artist.other_names || "")
      .split(/\s+/)
      .sort()
      .map(name =>
        UI.linkTo(name, "/artists", { search: { name: name }}, "ex-artist-other-name")
      )
      .join(", ");

    const groupLink = UI.linkTo(
      artist.group_name, "/artists", { search: { name: `group:${artist.group_name}` }}, "ex-artist-group-name"
    );

    return `
    <tr class="ex-artist">
      <td class="ex-artist-id">
	${UI.linkTo(`artist #${artist.id}`, `/artists/${artist.id}`)}
      </td>
      <td class="ex-artist-name category-${artist.tag.category}">
	${UI.linkTo("?", "/wiki_pages", { title: artist.name }, "wiki-link")}
	${UI.linkTo(artist.name, `/artists/${artist.id}`, {}, "artist-link")}
      </td>
      <td class="ex-artist-post-count">
	${UI.linkTo(artist.tag.post_count, "/posts", { tags: artist.name }, "search-tag")}
      </td>
      <td class="ex-artist-other-names">
	${otherNames}
      </td>
      <td class="ex-artist-group-name">
	${artist.group_name ? groupLink : ""}
      </td>
      <td class="ex-artist-status">
	${artist.is_banned ? "Banned" : ""}
	${artist.is_active ? ""       : "Deleted"}
      </td>
      <td class="ex-artist-created">
	${moment(artist.created_at).fromNow()}
      </td>
      <td class="ex-artist-updated">
	${moment(artist.updated_at).fromNow()}
      </td>
    </tr>
    `;
  }
}

class Comments {
  static initialize() {
    if ($("#c-comments").length || $("#c-posts #a-show").length) {
      $(function () {
        Comments.initializePatches();
        Comments.initializeMetadata($(".comments-for-post"));
      });
    }
  }

  static initializePatches() {
    $(window).on("danbooru:index_for_post", (event, post_id) => {
      const $parent = $(`.comments-for-post[data-post-id=${post_id}]`);
      Comments.initializeMetadata($parent);
    });
  }

  /*
   * Add 'comment #1234' permalink.
   * Add comment scores.
   */
  static initializeMetadata($parent) {
    $parent.find('.comment').each((i, e) => {
      const $menu = $(e).find('menu');

      const post_id = $(e).data('post-id');
      const comment_id = $(e).data('comment-id');
      const comment_score = $(e).data('score');

      if ($menu.children().length > 0) {
        $menu.append($('<li> | </li>'));
      }

      $menu.append($(`
        <li>
          <a href="/posts/${post_id}#comment-${comment_id}">Comment #${comment_id}</a>
        </li>
      `));

      $menu.append($(`
        <span class="info">
          <strong>Score</strong>
          <span>${comment_score}</span>
        </span>
      `));
    });
  }
}

class PostVersions {
  static initialize() {
    if ($("#c-post-versions #a-index").length && !UI.query("search[post_id]")) {
      PostVersions.initializeThumbnails();
    }
  }

  // Show thumbnails instead of post IDs.
  static initializeThumbnails() {
    let $post_column = $('tr td:nth-child(1)');
    let post_ids = $.map($post_column, e => $(e).text().match(/(\d+).\d+/)[1] );

    let post_data = [];
    let requests = _.chunk(post_ids, 100).map(function (ids) {
      let search = 'id:' + ids.join(',');

      return $.get(`/posts.json?tags=${search}`).then(data => {
        data.forEach(post => post_data[post.id] = post);
      });
    });

    Promise.all(requests).then(() => {
      $post_column.each((i, e) => {
        let post_id = $(e).text().match(/(\d+).\d+/)[1];
        $(e).html(Posts.preview(post_data[post_id]));
      });
    });
  }
}

var PostCount = Resource.PostCount = class PostCount extends Resource {
  static get primaryKey() { return "id"; }
  static get controller() { return "/counts/posts"; }

  static count(query) {
    return PostCount.index({ tags: query }).then(response => response.counts.posts);
  }
};

class SavedSearches {
  static initialize() {
    if ($("#c-saved-searches #a-index").length === 0) {
      return;
    }

    $("thead tr").replaceWith($(`
      <tr>
        <th id="ss-query" data-sort="string" data-sort-multicolumn="1,2,3">
          Query
        </th>
        <th id="ss-labels" data-sort="string" data-sort-multicolumn="2,1,3">
          Labels
        </th>
        <th id="ss-latest-post" data-sort="int" data-sort-multicolumn="2,3,1" data-sort-default="desc">
          Latest Post
        <th></th>
      </tr>
    `));

    $("tbody tr").each((i, row) => {
      $(`<td class="ss-latest-post"></td>`).insertBefore($(row).find(".links"));
    });

    $("tbody tr").each((i, row) => {
      const $search = $(row).find("td:first-child");
      const tags = $search.text();

      PostCount.count(tags).then(count => {
        $search.append(`<span class="post-count">${count}</span>`);
      });

      Post.index({ tags: tags, limit: 1 }).then(posts => {
        const post = _.first(posts);
        const post_link =
          (post === undefined)
          ? "<em>none</em>"
          : `<td data-sort-value="${post.id}"><a href="/posts/${post.id}">post #${post.id}</a>`;

        $(row).find(".ss-latest-post").replaceWith($(post_link));
      });
    });
  }
}

class Users {
  static get QTIP_SETTINGS() {
    return {
      overwrite: false,
      style: {
        classes: "qtip-bootstrap",
        tip: { corner: false },
      },
      show: {
        solo: true,
        ready: true
      },
      hide: {
        delay: 100,
        fixed: true,
      },
      position: {
        my: "top left",
        at: "top right",
        effect: false,
        adjust: {
          method: "flipinvert shift",
          resize: false,
          scroll: false,
          x: 10,
        }
      }
    };
  }

  static initialize() {
    this.initializeWordBreaks();

    if ($("#c-users #a-show").length) {
      this.initializeCollapsibleHeaders();
      this.initializeExpandableGalleries();
    }
  }

  // Wordbreak long usernames (e.g. GiantCaveMushroom) by inserting
  // wordbreaks at lowercase -> non-lowercase transitions.
  static initializeWordBreaks() {
    this.userLinks().html((i, name) =>
      name.replace(/([a-z])(?=[^a-z])/g, c => c + "<wbr>")
    );
  }

  // Add tooltips to usernames. Also add data attributes for custom CSS styling.
  static initializeUserTooltips() {
    // XXX triggers on Profile / Settings links on /static/site_map
    $(document).on("mouseover", '#page a[href^="/users/"]', e => {
        const $user = $(e.target);
        const userId = Users.parseUserId($user);

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

        const qtipParams = _.merge(Users.QTIP_SETTINGS, {
          show: { event: e.type },
          position: { viewport: $("#ex-viewport") },
          content: {
            text: (event, api) => {
              User.get(userId).then(user => {
                api.set("content.text", Users.renderExcerpt(user));
                api.reposition(event, false);
              });

              return "Loading...";
            },
          }
        });

        $user.qtip(qtipParams);
    });
  }

  static initializeCollapsibleHeaders () {
    $("#c-users #a-show > .box").each((i, e) => {
      const $gallery = $(e);

      // Make gallery headers collapsible.
      const $toggleCollapse = $(`<a class="ui-icon ui-icon-triangle-1-s collapsible-header"></a>`);
      $gallery.find("h2").prepend($toggleCollapse);

      $toggleCollapse.click(event => {
        $(event.target).closest("h2").next("div").slideToggle();
        $(event.target).toggleClass('ui-icon-triangle-1-e ui-icon-triangle-1-s');
        return false;
      });
    });
  }

  static initializeExpandableGalleries() {
    const user = $("#a-show > h1 > a").text().replace(/[\u200B-\u200D\uFEFF]/g, '').replace(" ", "_");

    // Rewrite /favorites link into ordfav: search so it's consistent with other post sections.
    $(".box a[href^='/favorites?user_id=']").attr(
      "href", `/posts?tags=ordfav:${encodeURIComponent(user)}`
    );

    $("#c-users #a-show > .box").each((i, e) => {
      const $gallery = $(e).addClass("ex-post-gallery");

      // Store the tag search corresponding to this gallery section in a data
      // attribute for the click handler.
      const [, tags] = $gallery.find('h2 a[href^="/posts"]').attr("href").match(/\/posts\?tags=(.*)/);
      $gallery.attr("data-tags", decodeURIComponent(tags));

      $gallery.find("> div").append(`
        <article class="ex-text-thumbnail">
          <a href="#">More »</a>
        </article>
      `);

      $gallery.find(".ex-text-thumbnail a").click(event => {
        const $gallery = $(event.target).closest(".ex-post-gallery");

        const limit = 30;
        const page = Math.trunc($gallery.find(".post-preview").children().length / limit) + 1;

        Post.index({ tags: $gallery.data("tags"), page, limit }).then(posts => {
          const html = posts.map(Posts.preview).join("");

          // Hide the original posts to avoid appending duplicate posts.
          $gallery.find("> div .post-preview:not(.ex-post-preview)").hide();

          // Append new posts, moving the "More »" link to the end.
          const $more = $gallery.find(".ex-text-thumbnail").detach();
          $gallery.find("> div").append(html, $more);

          $gallery.find(".ex-post-preview").trigger("ex.post-preview:create");
        });

        return false;
      });
    });
  }

  static renderExcerpt(user) {
    return `
      <section class="ex-excerpt ex-user-excerpt">
        <div class="ex-excerpt-title ex-user-excerpt-title">
          <span class="user-info">${User.render(user)}</span>
        </div>
        <div class="ex-excerpt-body ex-user-excerpt-body">
          <dl class="info">
            <dt>Joined</dt>
            <dd>${moment(user.created_at).fromNow()}</dd>
          </dl>
          <dl class="info">
            <dt>Uploads</dt>
            <dd>${user.post_upload_count}</dd>
          </dl>
          <dl class="info">
            <dt>Edits</dt>
            <dd>${user.post_update_count}</dd>
          </dl>
          <dl class="info">
            <dt>Notes</dt>
            <dd>${user.note_update_count}</dd>
          </dl>
          <dl class="info">
            <dt>Comments</dt>
            <dd>${user.comment_count}</dd>
          </dl>
          <dl class="info">
            <dt>Forum Posts</dt>
            <dd>${user.forum_post_count}</dd>
          </dl>
        </div>
      </section>
    `;
  }

  static userLinks() {
    return $('#page a[href^="/users/"]').filter((i, e) => this.parseUserId($(e)));
  }

  static parseUserId($user) {
    return _.nth($user.attr("href").match(/^\/users\/(\d+)$/), 1);
  }
}

class WikiPages {
  static initialize() {
    if ($("#c-wiki-pages").length === 0) {
      return;
    }

    WikiPages.initializeCollapsibleHeadings();
    WikiPages.initializeTableOfContents();
  }

  // Add collapse/expand button to headings.
  static initializeCollapsibleHeadings() {
    const $headings = $("#wiki-page-body :header");

    if ($headings.length < 3) {
      return;
    }

    $headings.prepend($('<a class="ui-icon ui-icon-triangle-1-s collapsible-header"></a>'));
    $headings.find("a.collapsible-header").click(e => {
      const $button = $(e.target);

      // Collapse everything up to the next heading at the same
      // level, or up to the alias/implication list at the bottom of the page.
      $button.toggleClass('ui-icon-triangle-1-e ui-icon-triangle-1-s');
      $button.parent('h1').nextUntil('p.hint, h1').slideToggle();
      $button.parent('h2').nextUntil('p.hint, h1, h2').slideToggle();
      $button.parent('h3').nextUntil('p.hint, h1, h2, h3').slideToggle();
      $button.parent('h4').nextUntil('p.hint, h1, h2, h3, h4').slideToggle();
      $button.parent('h5').nextUntil('p.hint, h1, h2, h3, h4, h5').slideToggle();
      $button.parent('h6').nextUntil('p.hint, h1, h2, h3, h4, h5, h6').slideToggle();
    });
  }

  // Add Table of Contents expandable.
  static initializeTableOfContents() {
    const $headings = $("#wiki-page-body :header");

    const hasToC =
      $("div.expandable-header > span")
      .filter((i, e) => $(e).text().match(/table of contents/i))
      .length > 0;

    if ($headings.length < 3 || hasToC) {
      return;
    }

    const $toc =
      DText.createExpandable(
        'Table of Contents',
        '<p class="tn">This table of contents was autogenerated by Danbooru EX.</p> <ul></ul>'
      ).prependTo('#wiki-page-body');

    // Build ToC. Create a nested heirarchy matching the hierarchy of
    // headings on the page; an h5 following an h4 opens a new submenu,
    // another h4 closes the submenu. Likewise for h5, h6, etc.
    let $submenu = null;
    let $menu = $toc.find('ul');
    let level = $headings.length > 0
              ? parseInt($headings.first().get(0).tagName[1])
              : undefined;

    $headings.each((i, e) => {
      const header = $(e).text();
      const anchor =
        'dtext-' + header.toLowerCase()
                    .replace(/[^a-z]+/g, '-')
                    .replace(/^-|-$/, '');

      const nextLevel = parseInt(e.tagName[1]);
      if (nextLevel > level) {
        $submenu = $('<ul></ul>');
        $menu.append($submenu);
        $menu = $submenu;
      } else if (nextLevel < level) {
        $menu = $menu.parent();
      }

      $(e).attr('id', anchor);
      $menu.append($(`<li><a href="#${anchor}">${header}</a></li>`));

      level = nextLevel;
    });
  }
}

class UI {
  static initialize() {
    UI.initializeFooter();
    UI.initializeMoment();

    EX.config.styleWikiLinks && UI.initializeWikiLinks();
    EX.config.useRelativeTimestamps && UI.initializeRelativeTimes();

    const $viewport = $('<div id="ex-viewport"></div>');
    $("body").append($viewport);
  }

  // Use relative times everywhere.
  static initializeRelativeTimes() {
    const ABS_DATE = /\d{4}-\d{2}-\d{2} \d{2}:\d{2}/;
    const absDates = $('time').filter((i, e) => $(e).text().match(ABS_DATE));

    absDates.each((i, e) => {
      const timeAgo = moment($(e).attr('datetime')).fromNow();
      $(e).text(timeAgo);
    });
  }

  static initializeFooter() {
    $("footer").append(
      `| Danbooru EX <a href="https://github.com/evazion/danbooru-ex">v${GM_info.script.version}</a> – <a href="/users/${$("body").attr('data-current-user-id')}/edit#ex-settings">Settings</a>`
    );
  }

  static initializeMoment() {
    moment.locale("en-short", {
      relativeTime : {
          future: "in %s",
          past:   "%s ago",
          s:  "1 second",
          ss:  "%d seconds",
          m:  "1 minute",
          mm: "%d minutes",
          h:  "1 hour",
          hh: "%d hours",
          d:  "1 day",
          dd: "%d days",
          M:  "1 month",
          MM: "%d months",
          y:  "1 year",
          yy: "%d years"
      }
    });

    moment.locale("en");
    moment.defaultFormat = "MMMM Do YYYY, h:mm a";
  }

  // Color code tags linking to wiki pages. Also add a tooltip showing the tag
  // creation date and post count.
  static initializeWikiLinks() {
    const parseTagName = wikiLink => decodeURIComponent($(wikiLink).attr('href').match(/\?title=(.*)$/)[1]);
    const metaWikis = /^(about:|disclaimer:|help:|howto:|list_of|pool_group:|tag_group:|template:)/i;
    const $wikiLinks = $(".dtext-wiki-link");

    const tags =
      _($wikiLinks.toArray())
      .map(parseTagName)
      .reject(tag => tag.match(metaWikis))
      .value();

    // Fetch tag data for each batch of tags, then categorize them and add tooltips.
    Tag.search(tags).then(tags => {
      tags = _.keyBy(tags, "name");
      $wikiLinks.each((i, e) => {
        const $wikiLink = $(e);
        const name = parseTagName($wikiLink);
        const tag = tags[name];

        if (name.match(metaWikis)) {
          return;
        } else if (tag === undefined) {
          $wikiLink.addClass('tag-dne');
          return;
        }

        const tagCreatedAt = moment(tag.created_at).format('MMMM Do YYYY, h:mm:ss a');
        const tagTitle = `${Tag.Categories[tag.category]} tag #${tag.id} - ${tag.post_count} posts - created on ${tagCreatedAt}`;
        $wikiLink.addClass(`tag-type-${tag.category}`).attr('title', tagTitle);

        if (tag.post_count === 0) {
          $wikiLink.addClass("tag-post-count-empty");
        } else if (tag.post_count < 1000) {
          $wikiLink.addClass("tag-post-count-small");
        }
      });
    });
  }

  static linkTo(name, path = "/", params = {}, ...classes) {
    const query = $.param(params);
    const href = (query === "")
               ? path
               : path + "?" + query;

    return `<a class="${_.escape(classes.join(" "))}" href="${href}">${_.escape(name)}</a>`;
  }

  static query(param) {
    return new URL(window.location).searchParams.get(param);
  }
}

UI.Header = Header;
UI.ModeMenu = ModeMenu;
UI.Notes = Notes;
UI.PostPreviews = PostPreviews;
UI.PreviewPanel = PreviewPanel;
UI.Sidebar = Sidebar;

UI.Artists = Artists;
UI.Comments = Comments;
UI.Posts = Posts;
UI.PostVersions = PostVersions;
UI.SavedSearches = SavedSearches;
UI.Users = Users;
UI.WikiPages = WikiPages;

___$insertStyle("#ex-header {\n  position: absolute;\n  top: 0;\n  padding: 5px 0;\n  width: 100%;\n  z-index: 100;\n  background: #1e1e2c;\n  border-bottom: 1px solid #2c2d3f;\n max-heigt: 32px; }\n  #ex-header .ex-header-wrapper {\n    display: flex; max-height:32px; }\n    #ex-header .ex-header-wrapper.ex-fixed.ex-header-scrolled {\n      border-bottom: 1px solid #EEEEEE;\n      box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1); }\n    #ex-header .ex-header-wrapper h1 {\n      display: inline-block;\n      font-size: 2.5em;\n      margin: 0 30px; }\n    #ex-header .ex-header-wrapper .ex-search-box {\n      margin: auto;\n      display: flex;\n      flex: 0 1 30%; }\n      #ex-header .ex-header-wrapper .ex-search-box input#ex-tags {\n        flex: 0 1 100%; }\n      #ex-header .ex-header-wrapper .ex-search-box input[type=\"submit\"] {\n        flex: 1;\n        margin: auto 1em; }\n    #ex-header .ex-header-wrapper .ex-mode-menu {\n      margin: auto;\n      flex: 1 2 70%; }\n      #ex-header .ex-header-wrapper .ex-mode-menu .ex-tag-script-controls {\n        display: inline-block;\n        margin: auto; }\n      #ex-header .ex-header-wrapper .ex-mode-menu label {\n        font-weight: bold;\n        cursor: auto; }\n        @media (max-width: 1280px) {\n          #ex-header .ex-header-wrapper .ex-mode-menu label {\n            display: none; } }\n    #ex-header .ex-header-wrapper .ex-header-close {\n      margin: auto;\n      margin-right: 30px;\n      color: #0073ff;\n      cursor: pointer; }\n\n@media (max-width: 1280px) {\n  header h1 {\n    font-size: 1.5em !important; } }\n\nh1.ex-small-header {\n  font-size: 1.5em !important; }\n\n/* Fix sidebar search box from rendering on top of EX header bar. */\n#page #sidebar input[type=\"text\"] {\n  z-index: 10 !important; }\n\n/* /posts/1234 */\n/* Move artist tags to top of the tag list. */\n#tag-list {\n  /*\n     * Break tags that are too long for the tag list (e.g.\n     * kuouzumiaiginsusutakeizumonokamimeichoujin_mika)\n     */\n  word-break: break-word; }\n  #tag-list .ex-tag-list-header h1, #tag-list .ex-tag-list-header h2 {\n    display: inline-block; }\n  #tag-list .ex-tag-list-header .post-count {\n    margin-left: 0.5em; }\n\n/*\n * Make the parent/child thumbnail container scroll vertically, not horizontally, to prevent\n * long child lists from blowing out the page width.\n */\n#has-parent-relationship-preview,\n#has-children-relationship-preview {\n  overflow: auto;\n  white-space: initial; }\n\n#c-posts #a-show {\n  /*\n    #image-container {\n        position: relative;\n        display: inline-block;\n\n        .desc {\n            display: none;\n        }\n    }\n\n    #note-container {\n        position: initial;\n\n        .note-box {\n            background: hsla(60,100%,97%,0.5);\n            outline: 1px solid white;\n            border: 1px solid black;\n            box-sizing: border-box;\n\n            .note-box-inner-border {\n                display: none;\n            }\n        }\n    }\n*/ }\n\n.ex-fit-width {\n  max-width: 100%;\n  height: auto !important; }\n\n.ex-post-gallery span h2 {\n  display: inline-block; }\n\n.ex-text-thumbnail {\n  display: inline-block;\n  float: left;\n  height: 154px;\n  width: 154px;\n  margin: 0 10px 10px 0;\n  text-align: center;\n  background: #EEEEEE;\n  border: 2px solid #DDDDDD; }\n  .ex-text-thumbnail a {\n    display: inline-block;\n    width: 100%;\n    height: 100%;\n    line-height: 154px; }\n\n.ex-panel-container {\n  display: flex;\n  min-height: 100vh; }\n  .ex-panel-container .ex-panel {\n    flex: 0 0 auto;\n    overflow: auto;\n    align-self: start; }\n  .ex-panel-container .ex-content-panel {\n    flex: 1 1;\n    margin-left: 0px !important; }\n  .ex-panel-container #ex-preview-panel {\n    position: sticky;\n    top: 3em;\n    max-height: calc(100vh - 127px);\n    overflow-y: auto;\n    overflow-x: hidden;\n    overscroll-behavior-y: contain; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar {\n      width: 5px;\n      height: 5px; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-button {\n      width: 0px;\n      height: 0px; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb {\n      background: #999999;\n      border: 0px none #FFFFFF;\n      border-radius: 0px; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb:hover {\n      background: #AAAAAA; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-thumb:active {\n      background: #AAAAAA; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track {\n      background: #EEEEEE;\n      border: 0px none #ffffff;\n      border-radius: 0px; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track:hover {\n      background: #EEEEEE; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-track:active {\n      background: #EEEEEE; }\n    .ex-panel-container #ex-preview-panel::-webkit-scrollbar-corner {\n      background: transparent; }\n    .ex-panel-container #ex-preview-panel .ex-no-image-selected {\n      text-align: center;\n      margin-top: 2em; }\n    .ex-panel-container #ex-preview-panel .ex-preview-panel-post {\n      font-size: 0.85714em;\n      line-height: 1.2em;\n      margin: 0 1em; }\n      .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata {\n        max-height: 9.95em;\n        margin-bottom: 1em;\n        overflow-y: auto; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar {\n          width: 5px;\n          height: 5px; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-button {\n          width: 0px;\n          height: 0px; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb {\n          background: #999999;\n          border: 0px none #FFFFFF;\n          border-radius: 0px; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb:hover {\n          background: #AAAAAA; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-thumb:active {\n          background: #AAAAAA; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track {\n          background: #EEEEEE;\n          border: 0px none #ffffff;\n          border-radius: 0px; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track:hover {\n          background: #EEEEEE; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-track:active {\n          background: #EEEEEE; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata::-webkit-scrollbar-corner {\n          background: transparent; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .fav-count {\n          margin-right: 0.25em; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .post-info {\n          margin-right: 1em;\n          color: #333;\n          white-space: nowrap; }\n          .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-metadata .ex-preview-panel-post-title .post-info h1 {\n            color: #000;\n            display: inline;\n            font-size: 1em;\n            margin-right: 0.25em; }\n      .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body {\n        display: flex;\n        flex-direction: column; }\n        .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body article.post-preview {\n          width: auto;\n          height: auto;\n          margin: auto;\n          justify-content: center; }\n          .ex-panel-container #ex-preview-panel .ex-preview-panel-post .ex-preview-panel-post-body article.post-preview img {\n            object-fit: scale-down;\n            max-width: 100%;\n            max-height: calc(90vh - 127px); }\n  .ex-panel-container .ex-vertical-resizer {\n    cursor: col-resize;\n    flex: 0 0 1px;\n    border: 0.5em solid #2c2d3f;\n    background: #2c2d3f;\n    transition: background 0.125s; }\n    .ex-panel-container .ex-vertical-resizer:hover {\n      background: #2c2d3f;\n      transition: background 0.125s; }\n\n.ex-tag-list.ex-tag-list-inline {\n  word-break: break-all; }\n  .ex-tag-list.ex-tag-list-inline h1 {\n    display: inline;\n    font-size: 1em; }\n  .ex-tag-list.ex-tag-list-inline ul {\n    display: inline; }\n    .ex-tag-list.ex-tag-list-inline ul li {\n      display: inline-block;\n      margin-right: 0.5rem; }\n\n#ex-viewport {\n  position: fixed;\n  top: 0;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  visibility: hidden;\n  margin: 4em; }\n\n.post-media {\n  max-width: 100%;\n  max-height: 100%;\n  width: auto;\n  height: auto;\n  box-sizing: border-box;\n  object-fit: contain; }\n\n/*\n.qtip {\n    max-width: 720px;\n    max-height: none;\n    border-width: 2px;\n    box-sizing: border-box;\n\n    .ex-excerpt {\n        &-title {\n            font-size: 1em;\n            margin-bottom: 1em;\n            padding-bottom: 1em;\n            border-bottom: 1px solid $dim-border-color;\n\n            // Baseline align the title so the bottom margin/padding is correct.\n            // https://blogs.adobe.com/webplatform/2014/08/13/one-weird-trick-to-baseline-align-text/\n            &::first-line {\n                line-height: 0px;\n            }\n\n            &::before {\n                content: \"\";\n                display: inline-block;\n                height: $line-height;\n            }\n        }\n\n      &-body {\n          display: flex;\n          flex-direction: row;\n      }\n    }\n\n    ::-webkit-scrollbar {\n        width: 5px;\n        height: 5px;\n    }\n    ::-webkit-scrollbar-button {\n        width: 0px;\n        height: 0px;\n    }\n    ::-webkit-scrollbar-thumb {\n        background: #999999;\n        border: 0px none #ffffff;\n        border-radius: 0px;\n    }\n    ::-webkit-scrollbar-thumb:hover {\n        background: #AAAAAA;\n    }\n    ::-webkit-scrollbar-thumb:active {\n        background: #AAAAAA;\n    }\n    ::-webkit-scrollbar-track {\n        background: $dim-border-color;\n        border: 0px none #ffffff;\n        border-radius: 0px;\n    }\n    ::-webkit-scrollbar-track:hover {\n        background: $dim-border-color;\n    }\n    ::-webkit-scrollbar-track:active {\n        background: $dim-border-color;\n    }\n    ::-webkit-scrollbar-corner {\n        background: transparent;\n    }\n}\n*/\n.post-media {\n  max-width: 100%;\n  max-height: 100%;\n  width: auto;\n  height: auto;\n  box-sizing: border-box;\n  object-fit: contain; }\n\n.qtip .ex-post-excerpt-title .post-info {\n  color: #333; }\n  .qtip .ex-post-excerpt-title .post-info.id {\n    color: #333;\n    font-weight: bold; }\n\n.qtip .ex-post-excerpt-body {\n  max-height: 350px; }\n\n.qtip .ex-post-excerpt-preview {\n  flex: 0 1;\n  width: auto;\n  height: auto;\n  overflow: visible; }\n\n.qtip .ex-post-excerpt-metadata {\n  flex: 1 1;\n  overflow-y: auto; }\n\n.qtip .ex-user-excerpt-body dl.info {\n  margin-right: 2em; }\n\n.ex-fixed {\n  position: fixed !important; }\n\n/* Overrides for Danbooru's responsive layout */\n@media screen and (max-width: 660px) {\n  body {\n    overflow-x: hidden; }\n  #ex-header input {\n    font-size: 1em; }\n  #ex-header {\n    text-align: initial;\n    line-height: initial; }\n  #nav {\n    display: block;\n    float: none;\n    font-size: 1em; }\n  header#top menu {\n    width: initial; }\n  header#top menu li a {\n    padding: 6px 5px; }\n  .ex-preview-panel-container {\n    display: block;\n    min-height: initial; }\n  #sidebar,\n  #ex-sidebar-resizer,\n  #ex-preview-panel-resizer,\n  #ex-preview-panel {\n    display: none !important; } }\n\n#notice {\n  top: 4.5em !important; }\n\n.ex-artists {\n  white-space: nowrap; }\n\n.ex-artist .ex-artist-id {\n  width: 10%; }\n\n.ex-artist .ex-artist-other-names {\n  width: 100%;\n  white-space: normal; }\n\n#c-artists #sidebar label {\n  display: block;\n  font-weight: bold;\n  padding: 4px 0 4px 0;\n  width: auto;\n  cursor: auto; }\n\n#c-artists #sidebar input[type=\"text\"] {\n  width: 100% !important; }\n\n#c-artists #sidebar button[type=\"submit\"] {\n  display: block;\n  margin: 4px 0 4px 0; }\n\n#c-artists #sidebar h2 {\n  font-size: 1em;\n  display: inline-block;\n  margin: 0.75em 0 0.25em 0; }\n\n#c-artists #a-index {\n  opacity: 0; }\n\n.ex-index {\n  opacity: 1 !important;\n  transition: opacity 0.15s; }\n\n#c-users #a-edit #ex-settings-section label {\n  display: inline-block; }\n\nbody.mode-tag-script {\n  background-color: #1e1e2c; }\n\nbody.mode-tag-script #ex-header {\n  border-top: 2px solid #D6D;\n  padding-top: 3px; }\n\nbody.mode-preview #ex-header {\n  border-top: 2px solid #0073ff;\n  padding-top: 3px; }\n\nbody.mode-view #ex-preview-panel-resizer {\n  display: none; }\n\nbody.mode-tag-script article.post-preview > a, #c-moderator-post-queues .post-preview aside > a, #c-comments .post-preview .preview > a,\nbody.mode-preview article.post-preview > a, #c-moderator-post-queues .post-preview aside > a, #c-comments .post-preview .preview > a {\n  width: 100%;\n  height: 100%; }\n  body.mode-tag-script article.post-preview > a:focus, #c-moderator-post-queues .post-preview aside > a:focus, #c-comments .post-preview .preview > a:focus,\n  body.mode-preview article.post-preview > a:focus, #c-moderator-post-queues .post-preview aside > a:focus, #c-comments .post-preview .preview > a:focus {\n    outline: none; }\n\nbody.mode-preview article.post-preview:hover, body.mode-preview #c-moderator-post-queues .post-preview aside:hover, body.mode-preview #c-comments .post-preview .preview:hover, body.mode-tag-script article.post-preview:hover, body.mode-tag-script #c-moderator-post-queues .post-preview aside:hover, body.mode-tag-script #c-comments .post-preview .preview:hover {\n  opacity: 0.75; }\n\nbody.mode-tag-script article.post-preview.ui-selected, body.mode-tag-script #c-moderator-post-queues .post-preview aside.ui-selected, body.mode-tag-script #c-comments .post-preview .preview.ui-selected {\n  background: #66b3cc; }\n  body.mode-tag-script article.post-preview.ui-selected img, body.mode-tag-script #c-moderator-post-queues .post-preview aside.ui-selected img, body.mode-tag-script #c-comments .post-preview .preview.ui-selected img {\n    opacity: 0.5; }\n\n#posts-container article.post-preview {\n  padding: 0 10px 10px 0;\n  margin: 0;\n  float: left; }\n\n#posts > div {\n  padding: 2px; }\n\narticle.post-preview.ex-cursor, #c-moderator-post-queues .post-preview aside.ex-cursor, #c-comments .post-preview .preview.ex-cursor {\n  z-index: 50;\n  outline: 2px solid #409fbf;\n  background-color: #b3d9e6; }\n\n.ui-selectable-helper {\n  position: absolute;\n  z-index: 100;\n  border: 1px dotted black; }\n\n.ui-selectable {\n  -ms-touch-action: none;\n  touch-action: none; }\n\n.ex-short-relative-time {\n  color: #333;\n  margin-left: 0.2em; }\n\n.tag-post-count-empty {\n  border-bottom: 1px dotted; }\n\n.tag-dne {\n  border-bottom: 1px dotted; }\n\n/* Ensure colorized tags are still hidden. */\n.spoiler:hover a.tag-type-1 {\n  color: #A00; }\n\n.spoiler:hover a.tag-type-3 {\n  color: #A0A; }\n\n.spoiler:hover a.tag-type-4 {\n  color: #0A0; }\n\n.spoiler:not(:hover) a {\n  color: black !important; }\n\n.paginator menu li {\n  line-height: 2.5em;\n  display: inline-block; }\n\na.collapsible-header {\n  display: none;\n  cursor: pointer;\n  margin-left: -16px; }\n\nh1:hover a.collapsible-header, h2:hover a.collapsible-header, h3:hover a.collapsible-header,\nh4:hover a.collapsible-header, h5:hover a.collapsible-header, h6:hover a.collapsible-header {\n  display: inline-block !important; }\n\n#wiki-page-body h1, #wiki-page-body h2, #wiki-page-body h3,\n#wiki-page-body h4, #wiki-page-body h5, #wiki-page-body h6 {\n  padding-left: 16px;\n  margin-left: -16px; }\n");

var EX = window.EX = class EX {
  static get Config() { return Config; }
  static get DText() { return DText; }
  static get Keys() { return Keys; }
  static get Resource() { return Resource; }
  static get UI() { return UI; }

  static get logLevel() { return 1; }

  static initialize() {
    // console.timeEnd("preinit");
    // console.groupCollapsed("settings");

    EX.version = GM_info.script.version;
    EX.config = new EX.Config();
    EX.keys = new EX.Keys();

    if (EX.config.enableHotkeys) { EX.keys.initialize(); }

    EX.config.enableHeader && UI.Header.initialize();
    EX.config.resizeableSidebars && UI.Sidebar.initialize();
    EX.config.showThumbnailPreviews && UI.PostPreviews.initializeThumbnailPreviews();
    // EX.config.showPostLinkPreviews && UI.PostPreviews.initializePostLinkPreviews();
    EX.UI.initialize();
    EX.config.enableNotesLivePreview && EX.UI.Notes.initialize();
    EX.config.usernameTooltips && EX.UI.Users.initializeUserTooltips();
    EX.config.enableLargeThumbnails && EX.UI.Posts.initializeLargeThumbnails();

    EX.config.artistsRedesign && EX.UI.Artists.initialize();
    EX.config.commentsRedesign && EX.UI.Comments.initialize();
    EX.config.postsRedesign && EX.UI.Posts.initialize();
    EX.config.postVersionsRedesign && EX.UI.PostVersions.initialize();
    EX.config.wikiRedesign && EX.UI.WikiPages.initialize();
    EX.config.usersRedesign && EX.UI.Users.initialize();
    // EX.UI.SavedSearches.initialize();

    // console.groupEnd("settings");
    // console.timeEnd("initialized");
  }

  static debug(...params) {
    if (EX.logLevel === 0) {
      console.log(...params);
    }
  }
};

window.EX.debug("Danbooru:", window.Danbooru);
// console.timeEnd("loaded");

$(function () {
  try {
    window.EX.initialize();
  } catch(e) {
    console.trace(e);
    $("footer").append(`<div class="ex-error">Danbooru EX error: ${e}</div>`);
    throw e;
  }
});

return EX;

}(_,Mousetrap,moment,ResizeSensor));

Thank you for fixing the header height issue of your danbooruEx version

Could you make a light-mode version, or have it follow the user preference? I'm using your (dark mode) version right now but I just don't like dark mode danbooru

redtails said:

Thank you for fixing the header height issue of your danbooruEx version

Could you make a light-mode version, or have it follow the user preference? I'm using your (dark mode) version right now but I just don't like dark mode danbooru

I wast just changing line ~1456 to force danbo header text small by default

patched line
...
        <div class="ex-header-wrapper">
          <h1 class="ex-small-header"><a href="/">Danbooru</a></h1>  // <-- change this line
          <form class="ex-search-box" action="/posts" accept-charset="UTF-8" method="get">
            <input type="text" data-autocomplete="tag-query" name="tags" id="ex-tags" class="ui-autocomplete-input" autocomplete="off">
...
1 3 4 5 6 7