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.

Mint 2020.12.05.. Lásd a legutóbbi verzió

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

You will need to install an extension such as Tampermonkey 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.

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

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

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.14
// @grant       GM.openInTab
// @grant       unsafeWindow
// ==/UserScript==

(function(unsafeWindow) {
  "use strict";
  const VERSION = "v0.99.14";

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

  let is_greasemonkey4 = false; //script breaking changes (see TODO)
  let 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(...args) {
    if (typeof window.console === "undefined") return;

    const msgs = args.map(a => (typeof a === "object" ? JSON.parse(JSON.stringify(a)) : a));

    window.console.log(...msgs);
  }

  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);
    console_log(msg);
  }

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

  const 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,
    editform_deleteuselesstags: 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,
  };

  const USE_LOCAL_STORAGE = (typeof(Storage) !== "undefined");
  const KEY_PREFIX = "config."; //used to avoid conflicts in localStorage

  let config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); //load default
  let dom_content_loaded = false;

  //config entries that need to have specific types
  const CONFIG_TYPES = {
    scale_mode: Number,
    tag_menu_layout: Number
  }

  //correct the type of a config entry
  function fix_config_entry(key, value) {
    const fixer = CONFIG_TYPES[key];
    if (fixer !== undefined) {
      return fixer(value);
    }

    return value;
  }

  function save_config(key, value, warn = true) {
    value = fix_config_entry(key, value);

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

      return;
    }

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

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

        if (USE_LOCAL_STORAGE) {
          const cfg_key = KEY_PREFIX + key;
          const stored_value = localStorage.getItem(cfg_key);
          if (stored_value !== null) {
            value = JSON.parse(stored_value);
          }
        }

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

  function local_storage_changed(e) {
    if (e.key.startsWith(KEY_PREFIX)) {
      const key = e.key.substring(KEY_PREFIX.length);
      const 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] = fix_config_entry(key, value);

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

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

    update_config_dialog_by_key(key);

    if (key === "hide_headerlogo") {
      update_headerlogo();
    }
  }

  function reset_config() {
    //clear local storage
    if (USE_LOCAL_STORAGE) {
      for (const key in config) {
        if (config.hasOwnProperty(key)) {
          const cfg_key = KEY_PREFIX + key;
          localStorage.removeItem(cfg_key);
        }
      }
    }

    for (const key in config) {
      if (config.hasOwnProperty(key)) {
        config_changed(key, DEFAULT_CONFIG[key]);
      }
    }
  }


  //template for the config dialog
  const 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\""},
    "editform_deleteuselesstags":         {type: "checkbox", desc: "\"Save changes\" button deletes useless_tags tag (if there have been changes)"},
    "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

    const 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 (const key in config) {
      if (config.hasOwnProperty(key)) {
        const cfg_key = KEY_PREFIX + key;
        const 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) {
    const cfg_key = KEY_PREFIX + key;
    const 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 (const 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
    const outer = document.createElement("DIV");
    outer.style.visibility = "hidden";
    outer.style.width = "100px";
    document.body.appendChild(outer);

    const widthNoScroll = outer.offsetWidth;

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

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

    const widthWithScroll = inner.offsetWidth;

    outer.parentNode.removeChild(outer);

    return widthNoScroll - widthWithScroll;
  }

  function get_original_background_color() {
    //the background-color style gets changed through the (site)script, but we need the original one
    //there has to be a better way than this, right?
    const current = window.getComputedStyle(document.body).getPropertyValue("background-color");
    document.body.style.backgroundColor = "";
    const original = window.getComputedStyle(document.body).getPropertyValue("background-color");
    document.body.style.backgroundColor = current;
    return original;
  }

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

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

  function rgb_array_shift(rgb, shift) {
    const shifted = [];
    for (let 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] + ")";
  }

  function is_darkmode() {
    const theme = unsafeWindow.Cookie.get("theme");
    if (theme !== "" && Number(theme) !== 0) {
      return true;
    }

    //fallback
    const rgb = rgb_to_array(get_original_background_color());
    return rgb_array_is_dark(rgb);
  }

  //helper function to adjust background colors based on light or dark mode
  function shifted_backgroundColor(shift) {
    const rgb = rgb_to_array(get_original_background_color());
    const shifted_rgb = rgb_array_shift(rgb, (is_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) {
    const observer = new MutationObserver(function (mutations) {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          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 */
  /*********************/

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


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

    const 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() {
    const 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 = get_original_background_color();
    cfg_dialog.style.zIndex = "10002";

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

    //generate the content of the config menu
    let 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 (const [key, value] of Object.entries(CONFIG_TEMPLATE)) {
      const type = value.type;

      const 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 (const [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() {
    const 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

    const 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%";

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

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

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

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

          const search_tags = search_field.value.trim().split(/\s+/);
          const 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;
        };
      };

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

        item.insertBefore(a, taglink);
        item.insertBefore(document.createTextNode(" "), taglink);
      }

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

        item.insertBefore(a, taglink);
        item.insertBefore(document.createTextNode(" "), taglink);
      }
    }
  }

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

    const elems = root.getElementsByTagName("SPAN");
    for (const elem of elems) {
      if (elem.classList.contains("thumb")) {
        add_speaker_icon(elem);
      }
    }
  }

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

    const icon = document.createElement("SPAN");
    const 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 */
  /***********************************************/

  let mode_dropdown = null;
  let added_mode_options = false;
  let 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) {
      if (mode_dropdown.options.namedItem("choose-parent") === null) {
        const option = document.createElement("option");
        option.text = "Choose Parent";
        option.value = "choose-parent";
        mode_dropdown.add(option);
      }

      if (mode_dropdown.options.namedItem("set-parent") === null) {
        const 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_init_workaround(); //guarantee that 'mode' variable correctly changes to new modes when loading page
    }
  }

  function PostModeMenu_init_workaround() {
    //issue: new post modes can reset on page load if they were added too late
    //reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then
    //calls PostModeMenu.change, which sets the cookie to mode_dropdown.value.
    //so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset
    //solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added

    const mode = unsafeWindow.Cookie.get("addon_mode");
    if (mode !== "") {
      unsafeWindow.Cookie.put("mode", mode, 7);
      mode_dropdown.value = mode;
    }

    PostModeMenu_change_override();
  }

  function PostModeMenu_change_override() {
    if (!added_mode_options) return;

    const s = mode_dropdown.value;
    unsafeWindow.PostModeMenu.change();
    unsafeWindow.Cookie.put("addon_mode", s, 7); //set 'backup' cookie

    const darkmode = is_darkmode();
    if (s === "add-fav") {
      //FFFFAA, original. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? "#505000" : "#FFA");
    } else if (s === "remove-fav") {
      //FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? "#504000" : "#FEA");
    } else if (s === "apply-tag-script") {
      //AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40
      document.body.style.backgroundColor = (darkmode ? "#500050" : "#FDF");
    } else if (s === "approve") {
      //2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: TODO
      document.body.style.backgroundColor = "#FDF";
    } else if (s === "choose-parent") {
      document.body.style.backgroundColor = (darkmode ? "#464600" : "#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 = (darkmode ? "#005050" : "#DFF");
      }
    }
  }

  let PostModeMenu_click_original = null;
  function PostModeMenu_click_override(post_id) {
    if (!added_mode_options) return false;

    if (PostModeMenu_click_original(post_id)) {
      return true; //view mode, let it click
    }

    const 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") {
      const 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 */
  /***********************/

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

  let 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) {
    const tag_menu = document.getElementById("tag_menu");
    if (tag_menu === null) return;

    const yFromBottom = window.innerHeight - e.clientY;
    let 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

    const 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 = get_original_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>";

    const 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

    const create_tag_menu_button = function(id, text) {
      const 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;
    };

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

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

    const 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() {
      if (tags_submit_listener()) {
        document.getElementById("edit-form").submit();
      }
      return false;
    };
    tag_menu.appendChild(tag_menu_save);
  }

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

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

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

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

    const create_tag_button = function(tag) {
      const 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;
    };

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

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

    const create_tag_list = function() {
      const 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
    const current_tags_flex = create_tag_list();
    current_tags_flex.style.marginBottom = "3px";
    const current_tags = get_tags_array();
    for (const current_tag of current_tags) {
      let div = create_top_level_div();
      div.appendChild(create_tag_button(current_tag));
      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" ]} ]
    let 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 (let k = 0; k < tag_data.length; k++) {
      const list_flex = create_tag_list();
      const list_name = tag_data[k].name;
      const 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;
      }

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

      const group_style = function(el) {
        //red in darkmode needs more contrast
        const rgb = rgb_to_array(get_original_background_color());
        if (is_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 (const list_tag of list_tags) {
        const is_array = Array.isArray(list_tag);

        //find tags_type
        let tags_type;
        if (is_array) {
          if (list_tag.length === 0) {
            show_notice("addon error: \"common tags\" \"tags\" array contains an empty array");
            return;
          }

          //check what the array consists of
          let all_arrays = true;
          let no_arrays = true;
          for (let i = 0; i < list_tag.length; i++) {
            if (!Array.isArray(list_tag[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) {
          const tags_table = [];
          for (let j = 0; j < list_tag.length; j++) {
            if (list_tag[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_tag[j][0].trim().split(/\s+/));
          }

          const table_height = tags_table.length;
          let table_width = 0;
          for (let 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)>
          const table = document.createElement("TABLE");
          table.style.display = "inline-block";
          group_style(table);
          table.style.marginBottom = "0";
          for (let row = 0; row < table_height; row++) {
            const tr = document.createElement("TR");
            for (let col = 0; col < table_width; col++) {
              const 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);
          }

          let 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_tag.length !== 1) {
            show_notice("addon error: \"common tags\" \"tags\" array contains a group with not exactly 1 tags string");
            return;
          }

          let tags = list_tag[0].trim().split(/\s+/);

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

          for (const tag of tags) {
            group_div.appendChild(wrap_in_div(create_tag_button(tag), "3px"));
          }

          let 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)>
          let tags = list_tag.trim().split(/\s+/);
          for (const tag of tags) {
            const div = create_top_level_div("4px 3px 2px 3px");
            div.appendChild(wrap_in_div(create_tag_button(tag)));
            list_flex.appendChild(div);
          }
        }
      }

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

      if (list_name && config.tag_menu_layout === 1) {
        const 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_tags_change_listener() {
    const post_tags_area = document.getElementById("post_tags");
    if (post_tags_area === null) return; //not logged in

    post_tags_area.addEventListener("input", function() {
      tags_changed = true;
        clearTimeout(tag_update_timer);
        tag_update_timer = setTimeout(function() {
          updated_tags();
        }, 500);
      });
    }

  //also used for 'tag_menu_save' button
  function tags_submit_listener() {
    delete_useless_tags_tag();
    return true; //actually submit?
  }

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

    edit_form.addEventListener("submit", function(e) {
      if (!tags_submit_listener()) {
        e.preventDefault();
      }
    });
  }

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

    actions_ul = li.parentElement;

    const action_links = actions_ul.getElementsByTagName("A");
    for (const action_link of action_links) {
      if (action_link.innerHTML === "Delete") {
        found_delete_action = true;
        break;
      }
    }
  }

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

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

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

      const 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() {
    const edit_form = document.getElementById("edit-form");
    if (edit_form === null) return; //not logged in

    const button_place = edit_form.children[1].children[0].children[0].children[0];
    button_place.style.whiteSpace = "nowrap";

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

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

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

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

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

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

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

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

    const 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) {
    const 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;

    tags_changed = true;
    updated_tags();
  }

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

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

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

    tags_changed = true;
    updated_tags();
  }

  function delete_useless_tags_tag() {
    if (tags_changed && config.editform_deleteuselesstags) {
      remove_tag("useless_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;
    tags_changed = false;
    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;
    }

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

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

    const tags = get_tags_array();
    const 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;
    }
    if (old_tags.indexOf("duplicate") === -1) {
      show_notice("addon: not tagged as duplicate!");
      return;
    }

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

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

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


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

    let 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"
      let res = null;
      const lowres = document.getElementById("lowres");
      if (lowres !== null) {
        res = lowres.innerHTML.split("x"); //parse "<width>x<height>"
      } else {
        const 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) {
    let img_rect_w, img_rect_h;
    let new_width, new_height;

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

    let left_side;

    //workaround for Galinoa's Sankaku Channel Dark
    //problem: seems to only work for bigger windows
    if (config.sankaku_channel_dark_compatibility) {
      const 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);
    const 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;


    const 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;
    const absolute_img_top = (image_data.is_flash ? image_data.non_img_elem : image_data.img_elem).getBoundingClientRect().top + window.pageYOffset;
    if (to_center) {
      const 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() {
    const 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"
  let 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
    const observer = register_observer(function(node) {
      return (node.value === "apply-tag-script");
    }, function(node, observer) {
      observer.disconnect();
      add_mode_options(node.parentNode);
      return false;
    });

    const dropdown = document.getElementById("mode");
    if (dropdown !== null) {
      for (const child of dropdown.children) {
        if (child.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
    const observer = register_observer(function(node) {return node.id === "image"; }, function(node, observer) { configure_video(node); return true; });
    const 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
    const 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);

    const 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_init_workaround(); //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) {
        if (mode_dropdown === null) return;
        if (e.ctrlKey || e.altKey || e.shiftKey) return;

        if (e.target === mode_dropdown) {
          e.preventDefault(); //e.g. 'v' would otherwise change to 'View Posts'
        } else {
          const tag = e.target.tagName.toLowerCase();
          if (tag === "input" || tag === "textarea" || tag === "select") {
            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/")) {

      const 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/buttons
      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) {
        const 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_tags_change_listener();
      add_tags_submit_listener();


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

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

      try {
        //add a "⚙" link to the edit tag page
        const h2 = document.getElementsByClassName("title")[0];
        const tag = new URL(window.location.href).searchParams.get("title");
        const 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);
      } catch (error) {
        show_notice("addon error: couldn't add \"⚙\" tag page link, check console");
        console_log(error);
      }

    }

  }

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