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ó

// ==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);