SankakuAddon

Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, muting videos, speaker icons on loud videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, duplicate tagging/flagging. Fully configurable using localStorage.

Version vom 04.12.2020. Aktuellste Version

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        SankakuAddon
// @namespace   SankakuAddon
// @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, muting videos, speaker icons on loud videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, duplicate tagging/flagging. Fully configurable using localStorage.
// @include     https://chan.sankakucomplex.com/*
// @include     https://idol.sankakucomplex.com/*
// @run-at      document-start
// @version     0.99.12
// @grant       GM.openInTab
// @grant       unsafeWindow
// ==/UserScript==

(function(unsafeWindow) {
  "use strict";
  var version = "v0.99.12";

  /*****************/
  /* compatibility */
  /*****************/

  var is_greasemonkey4 = false; //script breaking changes (see TODO)
  var is_monkey = false;        //Tampermonkey, Violentmonkey, Greasemonkey (all seem to support 'GM.' functions)

  if (typeof GM !== "undefined" && typeof GM.info === "object") {
    is_monkey = true;
    is_greasemonkey4 = (GM.info.scriptHandler === "Greasemonkey");
  }

  function console_log(msg) {
    var str;
    try {
      str = JSON.parse(JSON.stringify(msg));
    } catch {
      str = msg;
    }

    if (typeof window.console != "undefined") {
      window.console.log(str);
    }
  }

  function open_in_tab(url) {
    if (is_monkey) GM.openInTab(url, false);
    else window.open(url); //requires popup permission
  }

  function show_notice(msg) {
    if (unsafeWindow.notice) unsafeWindow.notice(msg);
    else console_log(msg);
  }

  /***************************/
  /* configuration functions */
  /***************************/

  var default_config = {
    scroll_to_image: true,
    scale_image: true, //and video
    scale_only_downscale: false,
    scale_flash: false,
    scale_mode: 0,
    scale_on_resize: false,
    scroll_to_image_center: true,
    video_pause: false,
    video_mute: true,
    set_video_volume: false,
    video_volume: 50,
    video_controls: true,
    show_speaker_icon: true,
    show_animated_icon: true,
    setparent_deletepotentialduplicate: false,
    hide_headerlogo: false,
    tag_search_buttons: true,
    tag_menu: true,
    tag_menu_scale: "30%",
    tag_menu_layout: 0,
    common_tags_json: "[ {\"name\":\"test tags\", \"tags\":[\"tag1 tag2\", [\"grouped_tag1 grouped_tag2\"], \"tag3 tag4\"] }, { \"tags\":[ \"next_line tag5 tag6\", [\"grouped_tag3 grouped_tag4\"] , \"tag7 tag8\"] }, {\"name\":\"another category\", \"tags\":[\"t1 t2 t3\"]} ]",
    sankaku_channel_dark_compatibility: false
  };


  var use_local_storage = (typeof(Storage) !== "undefined");
  var key_prefix = "config.";

  var config = JSON.parse(JSON.stringify(default_config)); //load default
  var dom_content_loaded = false;

  function save_config(key, value, warn = true) {
    if (!use_local_storage) {
      if (warn)
        show_notice("addon: couldn't save setting \"" + key + " = " + value + "\" to local storage. check permissions.");
      return;
    }

    var cfg_key = key_prefix + key;
    try {
      localStorage.setItem(cfg_key, JSON.stringify(value));
    } catch (error) {
      console_log("SankakuAddon: ", error);
      show_notice("addon: couldn't save setting \"" + key + " = " + value + "\" to local storage, check the console.");
    }
  }

  function load_config() {
    for (var key in config) {
      if (config.hasOwnProperty(key)) {
        var value = config[key]; //default already loaded

        if (use_local_storage) {
          var cfg_key = key_prefix + key;
          var stored_value = localStorage.getItem(cfg_key);
          if (stored_value) value = JSON.parse(stored_value);
        }

        config_changed(key, value); //fire regardless
      }
    }
  }

  function local_storage_changed(e) {
    if (e.key.startsWith(key_prefix)) {
      var key = e.key.substring(key_prefix.length);
      var value = JSON.parse(e.newValue);

      config_changed(key, value);
    }
  }

  //event that is fired whenever a setting changes in the config dialog or the local storage
  function config_changed(key, value) {
    config[key] = value;

    if (!dom_content_loaded) return; //UI hasn't loaded yet

    update_config_dialog_by_key(key);

    if (key === "hide_headerlogo") {
      update_headerlogo();
    } else if (key === "scale_on_resize") {
      if (value) add_scale_on_resize_listener();
      else       remove_scale_on_resize_listener();
    }
  }

  function reset_config() {
    //clear local storage
    if (use_local_storage) {
      for (var key in config) {
        if (config.hasOwnProperty(key)) {
          var cfg_key = key_prefix + key;
          localStorage.removeItem(cfg_key);
        }
      }
    }

    for (var key in config)
      if (config.hasOwnProperty(key))
        config_changed(key, default_config[key]);
  }


  //template for the config dialog
  var config_template = {
    "scroll_to_image":                    {type: "checkbox", desc: "Scroll to image/video when opening post"},
    "scroll_to_image_center":             {type: "checkbox", desc: "Scroll to center of image/video, else scroll to top"},
    "scale_image":                        {type: "checkbox", desc: "Scale image/video when opening post"},
    "scale_only_downscale":               {type: "checkbox", desc: "Only downscale"},
    "scale_flash":                        {type: "checkbox", desc: "Also scale flash videos"},
    "scale_on_resize":                    {type: "checkbox", desc: "Scale image on window resize", title: "This uses the 'scale image mode' setting, so it doesn't work well when using the manual scaling actions."},
    "scale_mode":                         {type: "select",   desc: "Scale image/video mode: ", options: {0: "Fit to window", 1: "Fit horizontally", 2: "Fit vertically"}},
    "video_pause":                        {type: "checkbox", desc: "Pause (non-flash) videos*"},
    "video_mute":                         {type: "checkbox", desc: "Mute (non-flash) videos*"},
    "set_video_volume":                   {type: "checkbox", desc: "Set (non-flash) video volume to: "},
    "video_controls":                     {type: "checkbox", desc: "Show video controls*"},
    "tag_search_buttons":                 {type: "checkbox", desc: "Show + - tag search buttons*"},
    "show_speaker_icon":                  {type: "checkbox", desc: "Show 🔊 icon on thumbnail if it has audio*"},
    "show_animated_icon":                 {type: "checkbox", desc: "Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*"},
    "setparent_deletepotentialduplicate": {type: "checkbox", desc: "Delete potential_duplicate tag when using \"Set Parent\""},
    "hide_headerlogo":                    {type: "checkbox", desc: "Hide header logo"},
    "sankaku_channel_dark_compatibility": {type: "checkbox", desc: "Galinoa's Sankaku Channel Dark compatibilty*"},
    "tag_menu":                           {type: "checkbox", desc: "Activate tag menu*:"},
    "common_tags_json":                   {type: "text",     desc: " Common tags list (JSON format)*"},
    "tag_menu_layout":                    {type: "select",   desc: "Tag menu layout: ", options: {0: "Normal", 1: "Vertically compact"}}
  };

  //whether a config element's value are accessed via '.value' (or otherwise '.checked')
  function is_value_element(key) {
    if (key === "video_volume")   return true; //"video_volume" is hardcoded in add_config_dialog()
    if (key === "tag_menu_scale") return true; //doesn't exist as an element, but it would be '.value' type

    var type = config_template[key]["type"];
    return (type === "select" || type === "text");
  }

  //calls f(cfg_elem, key, get_value) for each existing config element
  function foreach_config_element(f) {
    for (var key in config) {
      if (config.hasOwnProperty(key)) {
        var cfg_key = key_prefix + key;
        var cfg_elem = document.getElementById(cfg_key);
        if (cfg_elem === null) continue;

        (function(cfg_elem) {
          if (is_value_element(key)) {
            f(cfg_elem, key, function() { return cfg_elem.value; });
          } else {
            f(cfg_elem, key, function() { return cfg_elem.checked; });
          }
        })(cfg_elem);
      }
    }
  }

  function update_config_dialog_by_key(key) {
    var cfg_key = key_prefix + key;
    var cfg_elem = document.getElementById(cfg_key);
    if (cfg_elem !== null) {
      if (is_value_element(key)) {
        cfg_elem.value = config[key];
      } else {
        cfg_elem.checked = config[key];
      }
    }
  }

  function update_config_dialog() {
    for (var key in config)
      if (config.hasOwnProperty(key))
        update_config_dialog_by_key(key);
  }

  function update_headerlogo() {
    hide_headerlogo(config.hide_headerlogo);
  }

  function show_config_dialog(bool) {
    document.getElementById("cfg_dialog").style.display = (bool ? "table" : "none");
  }


  /********************/
  /* helper functions */
  /********************/

  function get_scrollbar_width() { //from Stack Overflow
    var outer = document.createElement("DIV");
    outer.style.visibility = "hidden";
    outer.style.width = "100px";
    document.body.appendChild(outer);

    var widthNoScroll = outer.offsetWidth;

    outer.style.overflow = "scroll"; //force scrollbars

    var inner = document.createElement("DIV");
    inner.style.width = "100%";
    outer.appendChild(inner);

    var widthWithScroll = inner.offsetWidth;

    outer.parentNode.removeChild(outer);

    return widthNoScroll - widthWithScroll;
  }

  //"rgb(r,g,b)" -> [int(r), int(g), int(b)]
  function rgb_to_array(rgb) {
    var arr = rgb.substring(rgb.indexOf("(") + 1, rgb.lastIndexOf(")")).split(/,\s*/);
    for (var i = 0; i < arr.length; i++)
      arr[i] = parseInt(arr[i]);
    return arr;
  }

  function rgb_array_is_dark(rgb_array) {
    var avg = 0;
    for (var i = 0; i < rgb_array.length; i++)
      avg += rgb_array[i];
    avg /= rgb_array.length;
    return avg <= 128;
  }

  function rgb_array_shift(rgb, shift) {
    var shifted = [];
    for (var i = 0; i < 3; i++)
      shifted[i] = Math.min(Math.max(rgb[i] + shift, 0), 255);
    return shifted;
  }

  //[r, g, b] -> "rgb(r,g,b)"
  function rgb_array_to_rgb(rgb) {
    if (rgb.length === 3)
      return "rgb(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ")";
    return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + rgb[3] + ")";
  }

  //inefficient helper
  function is_darkmode() {
    var rgb = rgb_to_array(window.getComputedStyle(document.body, null).getPropertyValue("background-color"));
    return rgb_array_is_dark(rgb);
  }

  //helper function to adjust background colors based on light or dark mode
  function shifted_backgroundColor(shift) {
    var rgb = rgb_to_array(window.getComputedStyle(document.body, null).getPropertyValue("background-color"));
    var darkmode = rgb_array_is_dark(rgb);
    var shifted_rgb = rgb_array_shift(rgb, (darkmode ? 1 : -1) * shift);
    return rgb_array_to_rgb(shifted_rgb);
  }

  //helper function to modify nodes on creation
  function register_observer(node_predicate, node_modifier) {
    var observer = new MutationObserver(function (mutations) {
      for (var i = 0; i < mutations.length; i++) {
        var added_nodes = mutations[i].addedNodes;
        for (var j = 0; j < added_nodes.length; j++) {
          var node = added_nodes[j];
          if (node_predicate(node)) {
            if (node_modifier(node, observer)) { //are we done?
              observer.disconnect();
              return;
            }
          }
        }
      }
    });
    observer.observe(document, {childList: true, subtree: true});
    return observer;
  }


  /*********************/
  /* general functions */
  /*********************/

  var header_offset_height = null; //distance needed to scroll if headerlogo is hidden/shown


  function hide_headerlogo(hide) {
    var headerlogo = document.getElementById("headerlogo");
    if (headerlogo === null) return;

    var visible = (headerlogo.style.display !== "none");

    headerlogo.style.display = (hide ? "none" : "");

    //scroll if needed
    if (visible === !!hide) window.scrollBy(0, (visible && hide ? -1 : 1) * header_offset_height);
  }

  function add_config_dialog() {
    var cfg_dialog = document.createElement("DIV");
    cfg_dialog.id = "cfg_dialog";
    cfg_dialog.style.display = "none"; //show_config_dialog() switches this with "table" so that centeringDiv works
    cfg_dialog.style.border = "1px solid " + shifted_backgroundColor(32);
    cfg_dialog.style.top = "50%";
    cfg_dialog.style.transform = "translateY(-50%)";
    cfg_dialog.style.position = "fixed";
    cfg_dialog.style.left = "0";
    cfg_dialog.style.right = "0";
    cfg_dialog.style.margin = "0 auto";
    cfg_dialog.style.overflow = "auto";
    cfg_dialog.style.backgroundColor = window.getComputedStyle(document.body, null).getPropertyValue("background-color");
    cfg_dialog.style.zIndex = "10002";

    var centeringDiv = document.createElement("DIV");
    centeringDiv.style.display = "table-cell";
    centeringDiv.style.verticalAlign = "middle";
    cfg_dialog.appendChild(centeringDiv);
    var innerDiv = document.createElement("DIV");
    innerDiv.style.margin = "12px";
    innerDiv.id = "cfg_dialog_inner";
    centeringDiv.appendChild(innerDiv);

    //generate the content of the config menu
    var innerDivHTML = "<div style='font-weight: bold;'>Sankaku Addon " + version + "</div>"
      + "<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid " + shifted_backgroundColor(32) + ";'>";

    //parse the config_template
    for (var [key, value] of Object.entries(config_template)) {
      var type = value.type;

      var generate_span = function (value) {
        return "<span style='vertical-align: middle;" + (value.title ? "cursor:help; text-decoration: underline dashed;" : "") + "' "
          + (value.title ? "title='" + value.title + "'" : "") + " >" + value.desc + "</span>";
      }

      innerDivHTML += "<div>"
      switch (type) {
        case "checkbox":
          innerDivHTML += "<input id='" + key_prefix + key + "' type='checkbox' style='vertical-align: middle;'>";
          innerDivHTML += generate_span(value);
          //hardcode 'video_volume' element:
          innerDivHTML += (key === "set_video_volume" ? "<input id='" + key_prefix + "video_volume' type='number' min='0' max='100' size='4'>%" : "");
          break;
        case "select":
          innerDivHTML += generate_span(value);
          innerDivHTML += "<select id='" + key_prefix + key + "'>";
          for (var [k, v] of Object.entries(value.options))
            innerDivHTML += "<option value=" + k + ">" + v + "</option>";
          innerDivHTML += "</select>";
          break;
        case "text":
          innerDivHTML += "<input id='" + key_prefix + key + "' style='vertical-align: middle;'>";
          innerDivHTML += generate_span(value);
          break;
      }
      innerDivHTML += "</div>";
    }

    innerDivHTML += "<div>";
    innerDivHTML +=  "<button id='config_close' style='cursor: pointer;'>Close</button>";
    innerDivHTML +=  "<button id='config_reset' style='cursor: pointer;'>Reset settings</button>";
    innerDivHTML += "</div>";
    innerDivHTML += "<div>&nbsp;*requires a page reload.</div>";

    innerDiv.innerHTML = innerDivHTML;

    document.body.appendChild(cfg_dialog);

    //add events
    document.getElementById("config_close").onclick = function() { show_config_dialog(false); return false; };
    document.getElementById("config_reset").onclick = function() { reset_config(); return false; };
    foreach_config_element(function(cfg_elem, key, get_value) {
      cfg_elem.addEventListener("change", function() {
        config_changed(key, get_value());
        save_config(key, get_value());
      });
    });
  }

  function add_config_button() {
    var navbar = document.getElementById("navbar");
    if (navbar === null) {
      show_notice("addon error: couldn't find \"navbar\" element! Config dialog disabled.");
      return;
    }

    navbar.style.whiteSpace = "nowrap"; //hack to fit config button

    var a = document.createElement("A");
    a.href = "#";
    a.onclick = function() { show_config_dialog(true); return false; };
    a.innerHTML = "<span style='font-size: 110%;'>⚙</span> Addon config";
    a.style.fontSize = "120%";

    var li = document.createElement("LI");
    li.className = "lang-select"; //match style of top bar
    li.appendChild(a);
    navbar.appendChild(li);
  }

  function add_tag_search_buttons() {
    var tagsidebar = document.getElementById("tag-sidebar");
    if (tagsidebar === null) return;

    var items = tagsidebar.getElementsByTagName("LI");
    for (var i = 0; i < items.length; i++) {
      var taglink = items[i].getElementsByTagName("A")[0];
      var tagname = taglink.innerHTML.replace(/ /g, "_"); // " " -> "_" hopefully this is the only edgecase

      //generates onclick events
      var tag_search_button_func = function (tagname) {
        return function () {
          var search_field = document.getElementById("tags");

          var search_tags = search_field.value.trim().split(/\s+/);
          var tag_index = search_tags.indexOf(tagname);

          //add tag if missing, remove if existing
          if (tag_index === -1) {
            search_field.value = (search_field.value + " " + tagname).trim();
          } else {
            search_tags.splice(tag_index, 1);
            search_field.value = search_tags.join(" ");
          }

          search_field.focus({preventScroll: true});

          return false;
        };
      };

      var a = document.createElement("A");
      a.href = "#";
      a.innerHTML = "+";
      a.onclick = tag_search_button_func(tagname);

      items[i].insertBefore(a, taglink);
      items[i].insertBefore(document.createTextNode(" "), taglink);

      a = document.createElement("A");
      a.href = "#";
      a.innerHTML = "-";
      a.onclick = tag_search_button_func("-" + tagname);

      items[i].insertBefore(a, taglink);
      items[i].insertBefore(document.createTextNode(" "), taglink);
    }
  }

  function add_speaker_icons(root) {
    if (root === null) return;

    var elems = root.getElementsByTagName("SPAN");
    for (var i = 0; i < elems.length; i++) {
      if (elems[i].classList.contains("thumb")) {
        add_speaker_icon(elems[i]);
      }
    }
  }

  function add_speaker_icon(thumb_span) {
    var img = thumb_span.querySelector(".preview");
    if (img === null) return;
    var a = thumb_span.getElementsByTagName("A");
    if (a.length === 0) return;

    var icon = document.createElement("SPAN");
    var tags = img.title.trim().split(/\s+/);

    if (config.show_speaker_icon && (tags.indexOf("has_audio") !== -1)) {
      icon.innerHTML = "🔊";
    } else if (config.show_animated_icon && (tags.indexOf("animated") !== -1 || tags.indexOf("video") !== -1)) {
      icon.innerHTML = "⏩";
    } else {
      return;
    }

    icon.className = "speaker_icon";
    icon.style.color = "#666";
    icon.style.position = "absolute";
    icon.style.top = "2px"; //account for border
    icon.style.right = "2px";
    icon.style.fontSize = "200%";
    icon.style.textShadow = "-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white";
    icon.style.transform = "translateX(50%) translateY(-50%)";

    a[0].style.display = "inline-block"; //makes the element fit its content
    a[0].style.position = "relative";
    a[0].appendChild(icon);
  }

  function add_speaker_icons_observer(predicate) {
    if (config.show_speaker_icon || config.show_animated_icon) { //don't hog CPU when disabled, but requires page reload to activate
      //this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
      register_observer(predicate, function (node, observer) {
        add_speaker_icons(node);
        return false; //listen forever
      });
    }
  }

  function configure_video(node) {
    if (node === null || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== "VIDEO") return;

    if (config.video_pause)      node.pause();
    if (config.set_video_volume) node.volume = config.video_volume / 100.0;
    if (config.video_mute)       node.muted = true;
    node.controls = config.video_controls;
  }


  /***********************************************/
  /* main page / visually similar page functions */
  /***********************************************/

  var mode_dropdown = null;

  var added_mode_options = false;
  var call_postmodemenu_change = false;
  function add_mode_options(dropdown) {
    if (added_mode_options) return;
    added_mode_options = true;
    mode_dropdown = dropdown;

    //override change event
    mode_dropdown.removeAttribute("onchange");
    mode_dropdown.onchange = PostModeMenu_change_override;

    if (!is_greasemonkey4) {
      var option;
      if (mode_dropdown.options.namedItem("choose-parent") === null) {
        option = document.createElement("option");
        option.text = "Choose Parent";
        option.value = "choose-parent";
        mode_dropdown.add(option);
      }
      if (mode_dropdown.options.namedItem("set-parent") === null) {
        option = document.createElement("option");
        option.text = "Set Parent";
        option.value = "set-parent";
        mode_dropdown.add(option);
      }
    }

    //add_mode_options() was called late
    if (call_postmodemenu_change) {
      PostModeMenu_change_override(); //guarantee that 'mode' variable correctly changes to new modes when loading page
    }
  }

  function PostModeMenu_change_override() {
    if (mode_dropdown === null) return;

    unsafeWindow.PostModeMenu.change();

    var s = mode_dropdown.value;
    if (s === "remove-fav") {
      document.body.style.backgroundColor = "#FEA"; //slightly more orange
    } else if (s === "apply-tag-script") {
      document.body.style.backgroundColor = "#FDF"; //weaken color intensity
    } else if (s === "approve") {
      document.body.style.backgroundColor = "#FDF"; //weaken color intensity
    } else if (s === "choose-parent") {
      document.body.style.backgroundColor = "#FFD";
    } else if (s === "set-parent") {
      if (unsafeWindow.Cookie.get("chosen-parent") === "") {
        show_notice("addon: Choose parent first!");
        mode_dropdown.value = "choose-parent";
        PostModeMenu_change_override();
      } else {
        document.body.style.backgroundColor = "#DFF";
      }
    }
  }

  var PostModeMenu_click_original = null;
  function PostModeMenu_click_override(post_id) {
    if (mode_dropdown === null) return;

    if (PostModeMenu_click_original)
      if (PostModeMenu_click_original(post_id))
        return true;

    var s = mode_dropdown.value;
    if (s === "choose-parent") {
      unsafeWindow.Cookie.put("chosen-parent", post_id);
      mode_dropdown.value = "set-parent";
      PostModeMenu_change_override();
    } else if (s === "set-parent") {
      var parent_id = unsafeWindow.Cookie.get("chosen-parent");
      unsafeWindow.TagScript.run(post_id, "parent:" + parent_id + (config.setparent_deletepotentialduplicate ? " -potential_duplicate" : ""));
    }
    return false;
  }

  /***********************/
  /* post page functions */
  /***********************/

  var post_parent_id = null; //input elem
  //original post/parent ids
  var post_id = null;
  var parent_id = null;
  var image_data = null;
  var resize_timer;
  var tag_update_timer;
  //set by find_actions_list():
  var actions_ul = null;
  var found_delete_action = false;

  var mouse_moved = false; //for tag_menu_scaler
  function tag_menu_scaler_mousedown(e) {
    e.preventDefault();
    mouse_moved = false;
    window.addEventListener("mousemove", tag_menu_scaler_mousemove);
    window.addEventListener("mouseup",   tag_menu_scaler_mouseup);
  }

  function tag_menu_scaler_mousemove(e) {
    e.preventDefault();
    mouse_moved = true;
    set_tag_menu_scale(e, false);
  }

  function tag_menu_scaler_mouseup(e) {
    e.preventDefault();
    if (mouse_moved)
      set_tag_menu_scale(e, true);

    window.removeEventListener("mousemove", tag_menu_scaler_mousemove);
    window.removeEventListener("mouseup",   tag_menu_scaler_mouseup);
  }

  function set_tag_menu_scale(e, save) {
    var tag_menu = document.getElementById("tag_menu");
    if (tag_menu === null) return;

    var yFromBottom = window.innerHeight - e.clientY;
    var yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight));
    yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + "%";

    tag_menu.style.height = yPercentfromBottom;

    if (save)
      save_config("tag_menu_scale", yPercentfromBottom, false);
  }


  function add_tag_menu() {
    if (document.getElementById("post_tags") === null) return; //not logged in

    var tag_menu = document.createElement("DIV");
    tag_menu.id = "tag_menu";
    tag_menu.style.display = "none";
    tag_menu.style.width = "100%";
    tag_menu.style.height = config.tag_menu_scale;
    tag_menu.style.position = "fixed";
    tag_menu.style.bottom = "0";
    tag_menu.style.overflow = "auto";
    tag_menu.style.backgroundColor = window.getComputedStyle(document.body, null).getPropertyValue("background-color");
    tag_menu.style.zIndex = "10001";
    document.body.appendChild(tag_menu);

    //the inner div ensures tag_menu_close button doesn't scroll with the content
    tag_menu.innerHTML = "<span style='width: calc(100% - 2px); height: 100%; overflow: auto;'>" + "<span id='common_tags'></span></span>" + "current tags:" + "<span id='current_tags'></span></div>";

    var tag_menu_scaler = document.createElement("DIV");
    tag_menu_scaler.id = "tag_menu_scaler";
    tag_menu_scaler.style.width = "100%";
    tag_menu_scaler.style.height = "6px";
    tag_menu_scaler.style.backgroundColor = shifted_backgroundColor(32);
    tag_menu_scaler.style.position = "absolute";
    tag_menu_scaler.style.top = "0";
    tag_menu_scaler.style.cursor = "ns-resize";
    tag_menu_scaler.style.zIndex = "10000";
    tag_menu_scaler.addEventListener("mousedown", tag_menu_scaler_mousedown);
    tag_menu.appendChild(tag_menu_scaler);
    tag_menu.style.paddingTop = tag_menu_scaler.style.height; //since tag_menu_scaler floats above the tags

    var create_tag_menu_button = function(id, text) {
      var button = document.createElement("DIV");
      button.id = id;
      button.style.border = "1px solid " + shifted_backgroundColor(32);
      button.style.width = "24px";
      button.style.height = "24px";
      button.style.position = "absolute";
      button.style.textAlign = "center";
      button.style.cursor = "pointer";
      button.style.backgroundColor = shifted_backgroundColor(16);
      button.innerHTML = "<span style='width: 100%; display: block; position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);'>" + text + "</span>";
      button.style.zIndex = "10001";
      return button;
    };

    var tag_menu_close = create_tag_menu_button("tag_menu_close", "X");
    tag_menu_close.style.right = "0";
    tag_menu_close.style.top = "0";
    tag_menu_close.onclick = function() { show_tag_menu(false); return false; };
    tag_menu.appendChild(tag_menu_close);

    var tag_menu_open = create_tag_menu_button("tag_menu_open", "«");
    tag_menu_open.style.position = "fixed";
    tag_menu_open.style.right = "0";
    tag_menu_open.style.bottom = "0";
    tag_menu_open.onclick = function() { show_tag_menu(true); update_tag_menu(); return false; };
    document.body.appendChild(tag_menu_open);

    var tag_menu_save = create_tag_menu_button("tag_menu_save", "Save changes");
    tag_menu_save.style.right = "36px";
    tag_menu_save.style.width = "140px";
    tag_menu_save.style.top = "0";
    tag_menu_save.style.fontWeight = "bold";
    tag_menu_save.onclick = function() {
      var form = document.getElementById("edit-form");
      if (form !== null)
        form.submit();
      return false;
    };
    tag_menu.appendChild(tag_menu_save);
  }

  function update_tag_menu() {
    if (document.getElementById("post_tags") === null) return; //not logged in

    var common_tags_elem  = document.getElementById("common_tags");
    var current_tags_elem = document.getElementById("current_tags");

    //tag menu disabled
    if (common_tags_elem === null || current_tags_elem === null)
      return;

    if (Number(config.tag_menu_layout) === 1) {
      common_tags_elem.style.display = "grid";
      common_tags_elem.style.gridTemplateColumns = "fit-content(5%) auto";
    } else {
      common_tags_elem.style.display = "";
    }

    var create_tag_button = function(tag) {
      var a = document.createElement("A");
      a.href = "#";
      a.style.paddingLeft = "5px";
      a.style.paddingRight = "5px";
      a.style.borderStyle = "solid";
      a.style.borderWidth = "1px";
      a.style.backgroundColor = (is_darkmode() ? "#000" : "#FFF"); //more contrast for tag buttons
      a.className = (tag_is_present(tag) ? "" : "tag_nonexistent");

      a.onclick = function(tag, a) {
        return function() {
          if (tag_is_present(tag)) {
            remove_tag(tag);
            a.className = "tag_nonexistent";
          } else {
            add_tag(tag);
            a.className = "";
          }
          return false;
        };
      }(tag, a);
      a.innerHTML = tag;
      return a;
    };

    var wrap_in_div = function(el, margin) {
      var div = document.createElement("DIV");
      div.style.margin = margin;
      div.style.float = "left";
      div.appendChild(el);
      return div;
    };

    var create_top_level_div = function(margin = "3px") {
      var div = document.createElement("DIV");
      div.style.margin = margin;
      return div;
    };

    var create_tag_list = function() {
      var div = document.createElement("DIV");
      div.style.display = "flex";
      div.style.flexWrap = "wrap";
      div.style.alignContent = "flex-start";
      div.style.alignItems = "flex-start";
      div.style.margin = "0";
      div.style.padding = "0";
      return div;
    };

    //generate tag button list for current tags
    var current_tags_flex = create_tag_list();
    current_tags_flex.style.marginBottom = "3px";
    var current_tags = get_tags_array();
    for (var i = 0; i < current_tags.length; i++) {
      var div = create_top_level_div();
      div.appendChild(create_tag_button(current_tags[i]));
      current_tags_flex.appendChild(div);
    }

    //replace current list with new one
    while (current_tags_elem.hasChildNodes())
      current_tags_elem.removeChild(current_tags_elem.lastChild);
    current_tags_elem.appendChild(current_tags_flex);

    //now add common tags
    //common_tags_json should be an array of objects with an optional string "name" field and an array "tags" field,
    //where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group)
    //or arrays of array containing one string (representing a table)
    //ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
    var tag_data;
    try {
      tag_data = JSON.parse(config.common_tags_json);
    } catch (error) {
      show_notice("addon error: \"common tags\" JSON syntax error");
      return;
    }

    if (!Array.isArray(tag_data)) {
      show_notice("addon error: \"common tags\" needs to be an array of objects");
      return;
    }

    while (common_tags_elem.hasChildNodes())
      common_tags_elem.removeChild(common_tags_elem.lastChild);

    for (var k = 0; k < tag_data.length; k++) {
      var list_flex = create_tag_list();
      var list_name = tag_data[k].name;
      var list_tags = tag_data[k].tags;

      if (!Array.isArray(list_tags)) {
        show_notice("addon error: a \"common tags\" object needs to have a \"tags\" array");
        return;
      }

      var TAGS_TYPES = {
        LIST: "list",  //e.g. "tag1 tag2"
        GROUP: "group", //e.g. ["tag1 tag2"]
        TABLE: "table"  //e.g. [["tag1 tag2"], ["tag3 tag4"]]
      };

      var group_style = function(el) {
        //red in darkmode needs more contrast
        var rgb = rgb_to_array(window.getComputedStyle(document.body, null).getPropertyValue("background-color"));
        var darkmode = rgb_array_is_dark(rgb);
        if (darkmode) {
          el.style.border = "1px solid " + rgb_array_to_rgb(rgb_array_shift(rgb, 96));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64));
        } else {
          el.style.border = "1px solid " + rgb_array_to_rgb(rgb_array_shift(rgb, -64));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32));
        }
      };

      for (var t = 0; t < list_tags.length; t++) {
        var is_array = Array.isArray(list_tags[t]);

        //find tags_type
        var tags_type;
        if (is_array) {
          if (list_tags[t].length === 0) {
            show_notice("addon error: \"common tags\" \"tags\" array contains an empty array");
            return;
          }

          //check what the array consists of
          var all_arrays = true;
          var no_arrays = true;
          for (var i = 0; i < list_tags[t].length; i++) {
            if (!Array.isArray(list_tags[t][i])) {
              all_arrays = false;
            } else {
              no_arrays = false;
            }
          }

          if (all_arrays) {
            tags_type = TAGS_TYPES.TABLE;
          } else if (no_arrays) {
            tags_type = TAGS_TYPES.GROUP;
          } else {
            show_notice("addon error: \"common tags\" \"tags\" array contains an array which is neither a group nor a table");
            return;
          }
        } else {
          tags_type = TAGS_TYPES.LIST;
        }

        if (tags_type === TAGS_TYPES.TABLE) {
          var tags_table = [];
          for (var j = 0; j < list_tags[t].length; j++) {
            if (list_tags[t][j].length !== 1) {
              show_notice("addon error: \"common tags\" \"tags\" array contains a table entry with not exactly 1 tags string");
              return;
            }

            tags_table.push(list_tags[t][j][0].trim().split(/\s+/));
          }

          var table_height = tags_table.length;
          var table_width = 0;
          for (var row = 0; row < tags_table.length; row++)
            table_width = Math.max(table_width, tags_table[row].length);

          //div (flexbox)><div><table><tr><td><div (button)>
          var table = document.createElement("TABLE");
          table.style.display = "inline-block";
          group_style(table);
          table.style.marginBottom = "0";
          for (var row = 0; row < table_height; row++) {
            var tr = document.createElement("TR");
            for (var col = 0; col < table_width; col++) {
              var td = document.createElement("TD");
              td.style.border = "none";
              td.style.padding = "0";
              if (tags_table[row][col])
                td.appendChild(wrap_in_div(create_tag_button(tags_table[row][col]), "1px"));
              tr.appendChild(td);
            }
            table.appendChild(tr);
          }

          var div = create_top_level_div("0 3px 0 3px");
          div.appendChild(table);
          list_flex.appendChild(div);
        } else if (tags_type === TAGS_TYPES.GROUP) {
          if (list_tags[t].length !== 1) {
            show_notice("addon error: \"common tags\" \"tags\" array contains a group with not exactly 1 tags string");
            return;
          }

          var tags = list_tags[t][0].trim().split(/\s+/);

          //<div (flexbox)><div><div (button)>
          var group_div = document.createElement("DIV");
          group_div.style.display = "inline-block";
          group_style(group_div);

          for (var i = 0; i < tags.length; i++) {
            group_div.appendChild(wrap_in_div(create_tag_button(tags[i]), "3px"));
          }

          var div = create_top_level_div("0 3px 0 3px");
          div.appendChild(group_div);
          list_flex.appendChild(div);
        } else /* if (tags_type === tag_types.LIST) */ {
          //<div (flexbox)><div><div (button)>
          var tags = list_tags[t].trim().split(/\s+/);
          for (var i = 0; i < tags.length; i++) {
            var div = create_top_level_div("4px 3px 2px 3px");
            div.appendChild(wrap_in_div(create_tag_button(tags[i])));
            list_flex.appendChild(div);
          }
        }
      }

      var span = document.createElement("SPAN");
      span.innerHTML = span.innerHTML = (list_name ? list_name + ":" : "");
      span.style.paddingTop = "2px";
      if (list_name) span.style.marginLeft = "2px";

      if (list_name && Number(config.tag_menu_layout) === 1) {
        var add_top_border = function(el) {
          el.style.borderTopWidth = "1px";
          el.style.borderTopStyle = "solid";
          el.style.borderTopColor = shifted_backgroundColor(32);
        };
        add_top_border(span);
        add_top_border(list_flex);
      }

      common_tags_elem.appendChild(span);
      common_tags_elem.appendChild(list_flex);
    }
  }

  function show_tag_menu(bool) {
    document.getElementById("tag_menu").style.display = (bool ? "" : "none");
    document.getElementById("tag_menu_open").style.display = (!bool ? "" : "none");
  }

  function add_tag_menu_change_listener() {
    var post_tags_area = document.getElementById("post_tags");
    if (post_tags_area !== null) {
      post_tags_area.addEventListener("change", function() {
        clearTimeout(tag_update_timer);
        tag_update_timer = setTimeout(function() {
          updated_tags();
        }, 500);
      });
    }
  }

  function find_actions_list() {
    var li = document.getElementById("add-to-pool");
    if (li === null) return;

    actions_ul = li.parentElement;

    var action_links = actions_ul.getElementsByTagName("A");
    for (var i = 0; i < action_links.length; i++) {
      if (action_links[i].innerHTML === "Delete") {
        found_delete_action = true;
        break;
      }
    }
  }

  function add_addon_actions() {
    if (actions_ul === null) return;

    var separator = document.createElement("H5");
    separator.innerHTML = "Addon actions";
    var newli = document.createElement("LI");
    newli.appendChild(separator);
    actions_ul.appendChild(newli);

    var add_action = function(func, name, id) {
      var a = document.createElement("A");
      a.href = "#";
      a.onclick = function() {func(); return false;};
      a.innerHTML = name;

      var newli = document.createElement("LI");
      newli.id = id;
      newli.appendChild(a);
      actions_ul.appendChild(newli);
    };

    add_action(function() { scale_image( 0, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image",              "scale-image-fit");
    add_action(function() { scale_image( 1, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image (Horizontal)", "scale-image-hor");
    add_action(function() { scale_image( 2, true); scroll_to_image(config.scroll_to_image_center); }, "Fit image (Vertical)",   "scale-image-ver");
    add_action(function() { scale_image(-1, true); scroll_to_image(config.scroll_to_image_center); }, "Reset image size",       "reset-image");

    if (parent_id === null) return; //not logged in

    if (post_id === null) {
      show_notice("addon error: couldn't find post id?! Flag duplicate feature disabled.");
      return;
    }

    add_action(function() { flag_duplicate(post_id, "");                       }, "Flag duplicate",              "flag-duplicate");
    add_action(function() { flag_duplicate(post_id, ", visually identical");   }, "Flag duplicate (identical)",  "flag-duplicate-identical");
    add_action(function() { flag_duplicate(post_id, " with worse quality");    }, "Flag duplicate (quality)",    "flag-duplicate-quality");
    add_action(function() { flag_duplicate(post_id, " with worse resolution"); }, "Flag duplicate (resolution)", "flag-duplicate-resolution");
  }

  function add_tag_buttons() {
    var edit_form = document.getElementById("edit-form");
    if (edit_form === null) return; //not logged in

    var button_place = edit_form.children[1].children[0].children[0].children[0];

    button_place.setAttribute("nowrap", "nowrap"); //hack to keep buttons from wrapping (not HTML5 conform, should use CSS)

    var el = document.createElement("BUTTON");
    el.id = "clear_parent_id_button";
    el.style.margin = "0 3px 0 6px";
    el.innerHTML = "Clear";
    el.onclick = function() { post_parent_id.clear(); return false;};
    post_parent_id.parentNode.appendChild(el);

    el = document.createElement("BUTTON");
    el.id = "reset_parent_id_button";
    el.style.margin = "0 3px";
    el.innerHTML = "Reset";
    el.onclick = function() { reset_parent_id(); return false;};
    post_parent_id.parentNode.appendChild(el);

    el = document.createElement("BUTTON");
    el.id = "tag_reset_button";
    el.style.margin = "0 3px 0 6px";
    el.innerHTML = "Reset";
    el.onclick = function() { reset_tags(); return false; };
    button_place.appendChild(el);

    el = document.createElement("BUTTON");
    el.id = "tag_dup_button";
    el.style.margin = "0 3px";
    button_place.appendChild(el);

    el = document.createElement("BUTTON");
    el.id = "tag_var_button";
    el.style.margin = "0 3px";
    button_place.appendChild(el);

    el = document.createElement("BUTTON");
    el.id = "tag_pot_button";
    el.style.margin = "0 3px";
    button_place.appendChild(el);
  }

  function update_tag_buttons() {
    var taglist = document.getElementById("post_tags");
    var dup_button = document.getElementById("tag_dup_button");
    var var_button = document.getElementById("tag_var_button");
    var pot_button = document.getElementById("tag_pot_button");

    if (taglist === null || dup_button === null || var_button === null || pot_button === null)
      return;

    var tags = get_tags_array();

    if (tags.indexOf("duplicate") === -1) {
      dup_button.onclick = function() {add_tag("duplicate"); return false;};
      dup_button.innerHTML = "Tag duplicate";
    } else {
      dup_button.onclick = function() {remove_tag("duplicate"); return false;};
      dup_button.innerHTML = "Untag duplicate";
    }

    if (tags.indexOf("legitimate_variation") === -1) {
      var_button.onclick = function() {add_tag("legitimate_variation"); return false;};
      var_button.innerHTML = "Tag legitimate_variation";
    } else {
      var_button.onclick = function() {remove_tag("legitimate_variation"); return false;};
      var_button.innerHTML = "Untag legitimate_variation";
    }

    pot_button.innerHTML = "Untag potential_duplicate";
    if (tags.indexOf("potential_duplicate") === -1) {
      pot_button.disabled = true;
    } else {
      pot_button.onclick = function() {remove_tag("potential_duplicate"); return false;};
      pot_button.disabled = false;
    }
  }

  function reset_parent_id() {
    post_parent_id.value = parent_id;
  }

  function get_old_tags_array() {
    return document.getElementById("post_old_tags").value.trim().split(/\s+/);
  }

  function get_tags_array() {
    return document.getElementById("post_tags").value.trim().split(/\s+/);
  }

  function add_tag(tag) {
    var tags = get_tags_array();

    if ((tag === "duplicate" && tags.indexOf("legitimate_variation") !== -1) || (tag === "legitimate_variation" && tags.indexOf("duplicate") !== -1)) {
      show_notice("addon: cannot tag as duplicate and legitimate_variation at the same time.");
      return;
    }

    if (tags.indexOf(tag) !== -1) {
      show_notice("addon: tag already present.");
      return;
    }

    document.getElementById("post_tags").value += " " + tag;

    updated_tags();
  }

  function remove_tag(tag) {
    var tags = get_tags_array();

    for (var i = 0; i < tags.length; i++) {
      if (tags[i] === tag) {
        tags[i] = "";
      }
    }

    document.getElementById("post_tags").value = tags.join(" ").trim();

    updated_tags();
  }

  function tag_is_present(tag) {
    return get_tags_array().indexOf(tag) !== -1;
  }

  function reset_tags() {
    document.getElementById("post_tags").value = document.getElementById("post_old_tags").value;
    updated_tags();
  }

  //event that gets called whenever post_tags changes
  function updated_tags() {
    update_tag_buttons();
    update_tag_menu();
  }



  //flag option with default text
  function flag_duplicate(id, reason_suffix) {
    if (is_greasemonkey4) {
      show_notice("addon error: 'Flag duplicate' not yet supported in Greasemonkey");
      return;
    }

    if (parent_id === null) {
      show_notice("addon: user not logged in");
      return false;
    }

    var current_parent_id = post_parent_id.value;
    if (current_parent_id !== parent_id) {
      show_notice("addon: parent id was changed but not saved!");
      return false;
    }

    if (!current_parent_id || current_parent_id.length === 0) {
      show_notice("addon: no parent id set!");
      return false;
    }

    var tags = get_tags_array();
    var old_tags = get_old_tags_array();
    if (tags.indexOf("duplicate") !== -1 && old_tags.indexOf("duplicate") === -1) {
      show_notice("addon: duplicate tag set but not saved!");
      return false;
    }
    if (old_tags.indexOf("duplicate") === -1) {
      show_notice("addon: not tagged as duplicate!");
      return false;
    }

    if (old_tags.indexOf("legitimate_variation") !== -1) {
      show_notice("addon: tagged as legitimate_variation, are you sure it is a duplicate?");
      return false;
    }

    var reason = prompt("Why should this post be reconsidered for moderation?", "duplicate of " + parent_id + reason_suffix);
    if (reason === null) {
      return false;
    }

    //TODO will not work on Greasemonkey at all
    new unsafeWindow.Ajax.Request("/post/flag.json", {
      parameters: {
        "id": id,
        "reason": reason
      },
      onComplete: function(response) {
        var resp = response.responseJSON;
        if (resp.success) {
          show_notice("Post was resent to moderation queue");
        } else {
          show_notice("Error: " + resp.reason);
        }
      }
    });
  }


  function read_image_data() {
    var data = {
      img_elem: null, //image or video
      non_img_elem: null, emb_elem: null, //flash or unknown
      is_flash: null,
      width: null,
      height: null,
      aspect_ratio: null,
      current_height: null //store current height separately, because flash is a bitch
    };

    var img = document.getElementById("image");
    if (img !== null) {
      //image or video
      data.img_elem = img;
      data.is_flash = false;

      //workaround for Galinoa's Sankaku Channel Dark: don't read .width/.height attributes but read "Details"
      var res = null;
      var lowres = document.getElementById("lowres");
      if (lowres !== null) {
        res = lowres.innerHTML.split("x"); //parse "<width>x<height>"
      } else {
        var highres = document.getElementById("highres");
        if (highres !== null) {
          res = highres.innerHTML.split(" ")[0].split("x"); //parse "<width>x<height> (<file size>)"
        }
      }

      if (res === null) {
        show_notice("addon error: image/video resolution not in \"Details\"?! Disabled scaling.");
        return null;
      }

      data.width  = res[0];
      data.height = res[1];
    } else {
      //flash or unknown
      data.non_img_elem = document.getElementById("non-image-content");
      if (!data.non_img_elem) return null;
      data.img_elem = data.non_img_elem.getElementsByTagName("OBJECT")[0];
      data.emb_elem = data.non_img_elem.getElementsByTagName("EMBED")[0];
      data.is_flash = (data.img_elem !== null && data.emb_elem !== null);

      img = data.img_elem; //object contains width/height

      data.width  = img.width;
      data.height = img.height;
    }

    data.aspect_ratio = data.width / data.height;
    data.current_height = data.height;
    return data;
  }

  //stretch image/video/flash, requires data from read_image_data()
  function scale_image(mode, always_scale) {
    var img_rect_w, img_rect_h;
    var new_width, new_height;

    mode = Number(mode);
    if (isNaN(mode)) {
      show_notice("addon error: scaling mode wasn't a number?!");
      return;
    }

    //read_image_data() failed
    if (image_data === null)
      return;

    if (!always_scale && (!config.scale_flash && image_data.is_flash))
      return;

    //reset image size
    if (mode === -1) {
      if (!image_data.is_flash) {
        image_data.img_elem.style.width = null;
        image_data.img_elem.style.height = null;
      } else {
        image_data.img_elem.width  = image_data.width;
        image_data.img_elem.height = image_data.height;
        image_data.emb_elem.width  = image_data.width;
        image_data.emb_elem.height = image_data.height;
      }
      image_data.current_height = image_data.height;

      //workaround for Galinoa's Sankaku Channel Dark
      if (config.sankaku_channel_dark_compatibility) {
        image_data.img_elem.style.paddingLeft = "";
        note_fix();
      }

      return;
    }

    var left_side;

    //workaround for Galinoa's Sankaku Channel Dark
    //problem: seems to only work for bigger windows
    if (config.sankaku_channel_dark_compatibility) {
    var sidebar = document.getElementsByClassName("sidebar")[0];
      left_side = (sidebar.getBoundingClientRect().right + 12);
      image_data.img_elem.style.paddingLeft = left_side + "px"; //don't hide behind sidebar
    } else {
      left_side = image_data.img_elem.getBoundingClientRect().left;
    }

    //target rect
    img_rect_w = Math.max(window.innerWidth - left_side - get_scrollbar_width() - 1, 1);
    img_rect_h = Math.max(window.innerHeight - 1, 1);
    var img_rect_aspect_ratio = img_rect_w / img_rect_h;

    //fit into window
    if (mode === 0) {
      mode = (image_data.aspect_ratio > img_rect_aspect_ratio ? 1 : 2);
    }

    //horizontal
    if (mode === 1) {
      new_width = Math.floor(img_rect_w);
      new_height = Math.floor(img_rect_w / image_data.aspect_ratio);
      //vertical
    } else if (mode === 2) {
      new_width = Math.floor(img_rect_h * image_data.aspect_ratio);
      new_height = Math.floor(img_rect_h);
    }

    if (!always_scale && (config.scale_only_downscale && (new_width > image_data.width || new_height > image_data.height)))
      return;


    var set_dimensions = function(obj, new_width, new_height) {
      obj.width  = new_width  + "px";
      obj.height = new_height + "px";
    };

    if (image_data.is_flash) {
      set_dimensions(image_data.img_elem, new_width, new_height);
      set_dimensions(image_data.emb_elem, new_width, new_height);
    } else {
      set_dimensions(image_data.img_elem.style, new_width, new_height);
    }

    image_data.current_height = new_height;
  }

  function scale_on_resize_helper() {
    clearTimeout(resize_timer);
    resize_timer = setTimeout(function() {
      if (config.scale_on_resize) scale_image(config.scale_mode, false);
    }, 100);
  }

  function add_scale_on_resize_listener() {
    window.addEventListener("resize", scale_on_resize_helper);
  }

  function remove_scale_on_resize_listener() {
    window.removeEventListener("resize", scale_on_resize_helper);
  }

  function scroll_to_image(to_center) {
    if (image_data === null) return;
    var absolute_img_top = (image_data.is_flash ? image_data.non_img_elem : image_data.img_elem).getBoundingClientRect().top + window.pageYOffset;
    if (to_center) {
      var top_of_centered_rect = absolute_img_top - (window.innerHeight - image_data.current_height) / 2;
      window.scrollTo(0, top_of_centered_rect);
    } else {
      window.scrollTo(0, absolute_img_top);
    }
  }

  //simple note fix for Galinoa's Sankaku Channel Dark (only for default image size)
  function note_fix() {
    var note_container = document.getElementById("note-container");
    if (note_container !== null && image_data !== null) {
      note_container.style.marginLeft = ((window.innerWidth - image_data.img_elem.clientWidth) / 2 - 8) + "px";
    }
  }


  /******************/
  /* document-start */
  /******************/

  load_config();

  /*************************************/
  /* main page / visually similar page */
  /*************************************/

  //skip language codes in pathnames like "/jp/post/show"
  var pathname = location.pathname;
  if (pathname.indexOf("/", 1) === 3) {
    pathname = pathname.substring(3);
  }

  if (pathname === "/" || pathname.startsWith("/post/similar")) {

    //try to add new modes right after the "Apply tag script" mode is added to prevent it being reset to "View posts" on page reloads
    //it's possible we are too late to observe its construction, so look for it afterwards immediately
    var observer = register_observer(function(node) {
      return (node.value === "apply-tag-script");
    }, function(node, observer) {
      observer.disconnect();
      add_mode_options(node.parentNode);
      return false;
    });

    var dropdown = document.getElementById("mode");
    if (dropdown !== null) {
      var children = dropdown.childNodes;
      for (var i = 0; i < children.length; i++) {
        if (children[i].value === "apply-tag-script") { //it's already there
          observer.disconnect(); //stop looking for it
          add_mode_options(dropdown);
        }
      }
    }

    //add thumbnail icons for dynamically loaded posts (from auto paging)
    if (config.show_speaker_icon || config.show_animated_icon) {
      add_speaker_icons_observer(function (node) { return (node.classList != null && node.classList.contains("content-page")); });
    }


  /*************/
  /* post page */
  /*************/

  } else if (pathname.startsWith("/post/show/")) {

    //mute/pause videos
    var observer = register_observer(function(node) {return node.id === "image"; }, function(node, observer) { configure_video(node); return true; });
    var video = document.getElementById("image");
    if (video !== null) {
      observer.disconnect();
      configure_video(video);
    }

    add_speaker_icons_observer(function (node) { return (node.id === "recommendations"); });

  /*************/
  /* user page */
  /*************/

  } else if (pathname.startsWith("/user/show/")) {

    add_speaker_icons_observer(function (node) { return (node.id === "recommendations"); });

  }




  /******************/
  /* content-loaded */
  /******************/

  function init() {
    dom_content_loaded = true;

    //sitefix for flagged posts not always showing red border
    //problem: "flagged" style is defined before "has-parent" and "has-children" CSS styles, so these two take priority
    //fix: just add another copy of the "flagged" style at the end
    var sheet = document.createElement("style");
    sheet.innerHTML  = "img.has-children {padding:0px;border:2px solid #A7DF38;} img.has-parent {padding:0px;border:2px solid #CCCC00;} img.flagged {padding: 0px; border: 2px solid #F00;}";
    sheet.innerHTML += " a.tag_nonexistent { color: #E00; }"; //custom style for tag menu
    document.body.appendChild(sheet);

    var headerlogo = document.getElementById("headerlogo");
    if (headerlogo !== null) {
      header_offset_height = headerlogo.offsetHeight;
      update_headerlogo();
    }

    add_config_dialog();
    add_config_button();
    update_config_dialog();

    //listen for config changes in other windows
    window.addEventListener("storage", local_storage_changed);

    if (config.show_speaker_icon || config.show_animated_icon) add_speaker_icons(document);


    /*************************************/
    /* main page / visually similar page */
    /*************************************/

    if (pathname === "/" || pathname.startsWith("/post/similar")) {

      if (config.tag_search_buttons) add_tag_search_buttons();

      if (!is_greasemonkey4) {
        PostModeMenu_click_original = unsafeWindow.PostModeMenu.click;
        //TODO will not work on Greasemonkey (need to replace click events just like with the mode change event)
        unsafeWindow.PostModeMenu.click  = PostModeMenu_click_override;
      }

      if (added_mode_options) {
        //add_mode_options() was called early, as it should
        PostModeMenu_change_override(); //guarantee that 'mode' variable correctly changes to new modes when loading page
      } else {
        //if not, catch up on it later
        call_postmodemenu_change = true;
      }

      document.addEventListener("keydown", function(e) {
        var tag = e.target.tagName.toLowerCase();
        if (tag === "input" || tag === "textarea" || tag === "select") return;
        if (mode_dropdown === null) return;

        if (e.ctrlKey || e.altKey || e.shiftKey) return;

        switch (e.key) {
          case "v":
            if (is_greasemonkey4) {
              show_notice("addon error: 'Set Parent' not yet supported in Greasemonkey");
              return;
            }
            mode_dropdown.value = "set-parent";
            break;
          case "c":
            if (is_greasemonkey4) {
              show_notice("addon error: 'Choose Parent' not yet supported in Greasemonkey");
              return;
            }
            mode_dropdown.value = "choose-parent";
            break;
          case "q":
            mode_dropdown.value = "rating-q";
            break;
          case "s":
            mode_dropdown.value = "rating-s";
            break;
          case "e":
            mode_dropdown.value = "rating-e";
            break;
        }

        PostModeMenu_change_override();
      }, true);


    /*************/
    /* post page */
    /*************/

    } else if (pathname.startsWith("/post/show/")) {

      var hidden_post_id_el = document.getElementById("hidden_post_id");
      if (hidden_post_id_el !== null) {
        post_id = hidden_post_id_el.innerHTML;
      } else {
        post_id = pathname.substring(pathname.lastIndexOf("/") + 1);
      }

      post_parent_id = document.getElementById("post_parent_id");
      if (post_parent_id !== null) {
        parent_id = post_parent_id.value;
      }

      find_actions_list();
      add_addon_actions();
      add_tag_buttons();
      if (config.tag_menu) add_tag_menu();
      updated_tags(); //initialize tag menu
      if (config.tag_search_buttons) add_tag_search_buttons();

      image_data = read_image_data();
      if (config.scale_image) scale_image(config.scale_mode, false);
      if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center);
      if (config.sankaku_channel_dark_compatibility) note_fix();

      document.addEventListener("keydown", function(e) {
        var tag = e.target.tagName.toLowerCase();
        if (tag === "input" || tag === "textarea" || tag === "select") return;

        if (e.ctrlKey || e.altKey || e.shiftKey) return;

        switch (e.key) {
          case "r": //r(eset)
            scale_image(-1, true);
            scroll_to_image(config.scroll_to_image_center);
            break;
          case "f": //f(it)
            scale_image(0, true);
            scroll_to_image(config.scroll_to_image_center);
            break;
          case "g": //g
            scale_image(1, true);
            scroll_to_image(config.scroll_to_image_center);
            break;
          case "h": //h
            scale_image(2, true);
            scroll_to_image(config.scroll_to_image_center);
            break;
          case "s": //s(imilar)
            if (post_id === null) {
              show_notice("addon error: couldn't find post id?!");
            } else {
              open_in_tab(location.origin + "/post/similar?id=" + post_id);
            }
            break;
          case "d": //d(elete)
            if (post_id === null) {
              show_notice("addon error: couldn't find post id?!");
            } else if (!found_delete_action) {
              show_notice("addon error: Delete action not found, no permission?");
            } else {
              open_in_tab(location.origin + "/post/delete/" + post_id);
            }
            break;
        }
      }, true);

      if (config.scale_on_resize) add_scale_on_resize_listener();

      add_tag_menu_change_listener();


    /**************/
    /* pool index */
    /**************/

    } else if (pathname.startsWith("/pool/index")) {

      //sitefix to show pool links even if missing english translation (they could not be clicked on otherwise)
      var pool_entries = document.getElementById("pool-index").getElementsByTagName("TABLE")[0].getElementsByTagName("TBODY")[0].getElementsByTagName("TR");

      for (var i = 0; i < pool_entries.length; i++) {
        var pool_name = pool_entries[i].getElementsByTagName("TD")[0].getElementsByTagName("A")[0];
        if (pool_name.innerHTML.trim().length === 0) pool_name.innerHTML = "&lt;missing English translation&gt;";
      }

    /*************/
    /* wiki page */
    /*************/

    } else if (pathname.startsWith("/wiki/show")) {

      //add a "⚙" link to the edit tag page
      var h2 = document.getElementsByClassName("title")[0];
      var tag = new URL(window.location.href).searchParams.get("title");
      var wiki_edit_link = document.createElement("A");
      wiki_edit_link.href = "/tag/edit?name=" + tag;
      wiki_edit_link.innerHTML = "⚙";
      wiki_edit_link.title = "Edit Tag";
      h2.appendChild(wiki_edit_link);

    }

  }

  if (document.readyState === "complete" || document.readyState === "loaded" || document.readyState === "interactive") {
    init();
  } else {
    document.addEventListener("DOMContentLoaded", init, false);
  }
})(typeof unsafeWindow !== "undefined" ? unsafeWindow : window);