SankakuAddon

Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.

Устаревшая версия за 23.12.2020. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name        SankakuAddon
// @namespace   SankakuAddon
// @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.
// @author      sanchan
// @version     0.99.24
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZHPKwRhGMc/dolYbeIgOewBOeyWKDlqFZflsFZZXGZmZ3fVzphmZpNclYuDchAXvw7+A67KlVKKlOTgL/DrIo3n3VUr8U7vPJ++7/t9et/vC6FUybC8+gGwbN9NTyRjs9m5WOMTETpoox00w3Mmp8cz/Dveb6hT9Tqhev2/78/RkjM9A+qahIcNx/WFR4VTy76jeEO4wyhqOeF94bgrBxS+ULpe5UfFhSq/KnYz6TEIqZ6xwg/Wf7BRdC3hfuEeq1Q2vs+jbhIx7ZlpqV0yu/FIM0GSGDplFinhk5BqS2Z/+wYqvimWxGPI32EFVxwFiuKNi1qWrqbUvOimfCVWVO6/8/TyQ4PV7pEkNDwEwUsvNG7B52YQfBwEwechhO/hzK75lySnkTfRN2tazx5E1+DkvKbp23C6Dp13juZqFSksM5TPw/MxtGah/Qqa56tZfa9zdAuZVXmiS9jZhT7ZH134ArhcZ+m/WStSAAAACXBIWXMAAAsSAAALEgHS3X78AAAAeElEQVQ4y2NgoCX4Xyb7H4TxqWHCo7keG5toA4CgAQebsAHYbMTlCiYibMfrCiZibcIlx0SE3/GGBRM+Gxi7HjeCMD41TESGPE5XMOGzHRsbnxcIxbsDDAMts4cbjmR7A4kpvQHkMiZCKY1QSmXBZTKedNBA1RwLAFCeNCTVhz2FAAAAAElFTkSuQmCC
// @match       https://chan.sankakucomplex.com/*
// @match       https://idol.sankakucomplex.com/*
// @run-at      document-start
// @noframes
// @grant       GM.registerMenuCommand
// @grant       GM.addStyle
// @grant       GM.openInTab
// @grant       GM.setValue
// @grant       GM.getValue
// @grant       GM.deleteValue
// @grant       GM.addValueChangeListener
// @grant       unsafeWindow
// ==/UserScript==

(async function(unsafeWindow) {
  'use strict';

  const VERSION = 'v0.99.24';

  // based on the Tag Checklist in the wiki
  const DEFAULT_TAGLIST =
`[
  {
    "name": "People & Gender",
    "tags": [
      [
        ["female female_only 1girl 2girls 3girls 4girls 5girls 6+girls"],
        ["male male_only 1boy 2boys 3boys 4boys 5boys 6+boys"],
        ["futanari futanari_only 1_futanari 2_futanari 3_futanari 4_futanari 5_futanari 6+_futanari"],
        ["newhalf newhalf_only 1_newhalf 2_newhalf 3_newhalf 4_newhalf 5_newhalf 6+_newhalf"]
      ],
      "no_humans"
    ]
  },
  {
    "name": "Young Age",
    "tags": [
      "child loli shota toddlercon young"
    ]
  },
  {
    "name": "Androgynous",
    "tags": [
      "androgynous crossdressing genderswap trap reverse_trap"
    ]
  },
  {
    "name": "Group",
    "tags": [
      "solo duo trio quartet quintet sextet group"
    ]
  },
  {
    "name": "Relationship",
    "tags": [
      "couple siblings sisters brothers brother_and_sister twins triplets"
    ]
  },
  {
    "name": "Who/Other",
    "tags": [
      "anthropomorphization multiple_persona elf mecha monster monster_girl magical_girl fairy"
    ]
  },
  {
    "name": "Face",
    "tags": [
      "face ears eyes nose lips teeth facial_mark facial_hair beard"
    ]
  },
  {
    "name": "Upper Body",
    "tags": [
      "arms armpits armpit_crease armpit_peek back bare_shoulders breasts bust clavicle midriff navel stomach hands fingers"
    ]
  },
  {
    "name": "Lower Body",
    "tags": [
      "anus ass mound_of_venus vagina penis thighs knees feet barefoot legs bare_legs bare_thighs zettai_ryouiki toes"
    ]
  },
  {
    "name": "Breasts",
    "tags": [
      "cleavage breasts nipples areolae puffy_areolae areola_slip breasts_out_of_clothes breasts_apart underboob sideboob"
    ]
  },
  {
    "name": "Breast Size",
    "tags": [
      "pettanko small_breasts medium_breasts huge_breasts large_breasts gigantic_breasts alternate_bust_size"
    ]
  },
  {
    "name": "Skin Color",
    "tags": [
      "albino pale_skin dark_skin tanned red_skin blue_skin shiny_skin"
    ]
  },
  {
    "name": "Hairstyle",
    "tags": [
      "ahoge bangs blunt_bangs bob_cut double_bun drill_hair hair_over_one_eye peek-a-boo_bang ponytail side_pony_tail single_braid spiky_hair twinbraids twintails alternate_hairstyle two_side_up"
    ]
  },
  {
    "name": "Hair Length",
    "tags": [
      "very short hair short_hair medium_hair long_hair very_long_hair absurdly long hair"
    ]
  },
  {
    "name": "Hair/Eye Color",
    "tags": [
      [
        ["blonde      black_hair blue_hair brown_hair green_hair grey_hair orange_hair pink_hair purple_hair red_hair silver_hair"],
        ["golden_eyes black_eyes blue_eyes brown_eyes green_eyes grey_eyes orange_eyes pink_eyes purple_eyes red_eyes silver_eyes"]
      ],
      "white_hair"
    ]
  },
  {
    "name": "Animal Parts",
    "tags": [
      "animal_ears bat_wings bunny_ears cat_tail wolf_ears fang horns kitsunemimi nekomimi tail inumimi wings angel_wings"
    ]
  },
  {
    "name": "Look/Other",
    "tags": [
      "chibi mole muscle pointed_ears pregnant scar curvy animal_ear_fluff fluffy_tail"
    ]
  },
  {
    "name": "Swimwear",
    "tags": [
      "bikini one-piece_swimsuit swimsuit competition_swimsuit sukumizu"
    ]
  },
  {
    "name": "Facewear",
    "tags": [
      "megane sunglasses eyewear_on_head red-framed_eyewear"
    ]
  },
  {
    "name": "Upper Body",
    "tags": [
      "sailor_collar choker shirt crop_top camisole dress bra babydoll"
    ]
  },
  {
    "name": "Lower Body",
    "tags": [
      "skirt pleated_skirt pantsu thighhighs shoes sandals socks pants shorts short_shorts"
    ]
  },
  {
    "name": "Traditional\u00A0Clothes",
    "tags": [
      "serafuku kimono kindergarten_uniform chinese_clothes"
    ]
  },
  {
    "name": "Wear/Other",
    "tags": [
      "armor suit uniform school_uniform underwear_only nude completely_nude"
    ]
  },
  {
    "name": "Actions",
    "tags": [
      "battle fighting jumping running princess_carry stretch sleeping lying flying squatting"
    ]
  },
  {
    "name": "Posture",
    "tags": [
      "all_fours arched_back back-to-back bent-over fighting_stance leaning leaning_back leaning_forward squat top-down_bottom-up"
    ]
  },
  {
    "name": "Arms",
    "tags": [
      "arms_behind_back arms_crossed arm_support arm_up arms_up arms_behind_head chin_rest outstretched_arm outstretched_arms spread_arms v_arms"
    ]
  },
  {
    "name": "Hands",
    "tags": [
      "hands_clasped hand_in_pocket hands_in_pocket hand_on_cheek hand_on_hat hand_on_head hand_on_hip hands_on_hip hand_on_shoulder holding_hands interlocked_fingers outstretched_hand"
    ]
  },
  {
    "name": "Legs",
    "tags": [
      "knees_on_chest leg_lift leg_up legs_up outstretched_leg pigeon_toed spread_legs"
    ]
  },
  {
    "name": "Sitting",
    "tags": [
      "sitting crossed_legs indian_style leg_hug seiza sitting_on_lap sitting_on_person wariza yokozuwari straddling"
    ]
  },
  {
    "name": "Standing",
    "tags": [
      "standing crossed_legs_(standing) standing_on_one_leg"
    ]
  },
  {
    "name": "Lying",
    "tags": [
      "lying on_back on_side on_stomach"
    ]
  },
  {
    "name": "Viewing Direction",
    "tags": [
      "eye_contact looking_at_viewer looking_back looking_away"
    ]
  },
  {
    "name": "Gesture",
    "tags": [
      "clenched_hand clenched_hands double_v heart_hands pinky_out pointing shushing thumbs_up \\\\n\\/ \\\\m\\/ reaching salute waving cat_pose paw_pose v claw_pose double_\\\\m\\/"
    ]
  },
  {
    "name": "Facial Expressions",
    "tags": [
      "expressions expressionless ahegao anger_vein blush blush_stickers clenched_teeth closed_eyes evil naughty_face nosebleed open_mouth parted_lips pout rolleyes frown tears scream"
    ]
  },
  {
    "name": "Emotions",
    "tags": [
      "angry annoyed embarassed happy sad scared surprised worried disappointed",
      "drunk, trembling"
    ]
  },
  {
    "name": "Sex",
    "tags": [
      "sex anal clothed_sex happy_sex vaginal yaoi yuri tribadism oral"
    ]
  },
  {
    "name": "Positions",
    "tags": [
      "69 doggystyle girl_on_top cowgirl_position reverse_cowgirl_position upright_straddle missionary"
    ]
  },
  {
    "name": "Stimulation",
    "tags": [
      "buttjob footjob grinding thigh_sex tekoki caressing_testicles double_handjob masturbation crotch_rub paizuri naizuri"
    ]
  },
  {
    "name": "Oral",
    "tags": [
      "oral breast_sucking cunnilingus facesitting fellatio deepthroat :>="
    ]
  },
  {
    "name": "Groping",
    "tags": [
      "groping ass_grab breast_grab nipple_tweak self_fondle torso_grab"
    ]
  },
  {
    "name": "Group Sex",
    "tags": [
      "group_sex gangbang double_penetration orgy spitroast teamwork threesome"
    ]
  },
  {
    "name": "Insertion",
    "tags": [
      "insertion anal_insertion large_insertion stomach_bulge multiple_insertions urethral_insertion penetration nipple_penetration fingering anal_fingering"
    ]
  },
  {
    "name": "Fetishes",
    "tags": [
      "milf giantess minigirl plump fat skinny public public_nudity zenra exhibitionism voyeurism futa_on_female futa_on_male incest twincest rape about_to_be_raped molestation bestiality impregnation tentacles virgin vore"
    ]
  },
  {
    "name": "Bondage",
    "tags": [
      "bondage bdsm asphyxiation breast_bondage shibari spreader_bar suspension femdom humiliation body_writing slave spanked torture bound_arms bound_legs bound_wrists suspension"
    ]
  },
  {
    "name": "Semen",
    "tags": [
      "semen bukkake dripping_semen semen_splatter semen_pool nakadashi semen_in_anus semen_in_mouth semen_on_tongue semen_on_body semen_on_hair semen_on_lower_body semen_on_ass semen_on_vagina semen_on_upper_body semen_on_breasts semen_on_clothes ejaculation ejaculating_while_penetrated facial"
    ]
  },
  {
    "name": "Objects",
    "tags": [
      "condom used_condom sex_toy dildo vibrator"
    ]
  },
  {
    "name": "Bodily Fluids",
    "tags": [
      "blood lactation urinating saliva sweat female_ejaculation vaginal_juices"
    ]
  },
  {
    "name": "View",
    "tags": [
      "cross-section internal_cumshot x-ray"
    ]
  },
  {
    "name": "Background",
    "tags": [
      "white_background pink_background blue_background simple_background gradient_background two-tone_background ambiguous_background"
    ]
  },
  {
    "name": "Placement",
    "tags": [
      "indoors outdoors rooftop city pool beach cave bedroom hallway"
    ]
  },
  {
    "name": "Nature",
    "tags": [
      "ocean river tree palm_tree wisteria lilac white_flower blue_flower grass sand water"
    ]
  },
  {
    "name": "Indoors",
    "tags": [
      "pillow bed door bed_sheet counter window curtains bathtub"
    ]
  },
  {
    "name": "Work Type",
    "tags": [
      "scan watercolor_(medium) papercraft non-web_source photoshop_(medium) sketch work_in_progress lineart"
    ]
  }
]`;

  /*****************/
  /* 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 open_in_tab(url) {
    if (IS_MONKEY) GM.openInTab(url, false);
    else           window.open(url); // requires popup permission
  }

  function add_style(css) {
    if (IS_MONKEY) {
      GM.addStyle(css);
    } else {
      const sheet = document.createElement('STYLE');
      sheet.innerHTML = css;
      document.body.appendChild(sheet);
    }
  }

  // the site uses a ton of ancient, non-standard polyfills/prototype overrides, e.g.
  // Array.from(new Set([1])) returns [] instead of [1]
  // JSON.parse(JSON.stringify([1])) returns "[1]" instead of [1]
  // Array.from(s) can be replaced by [...s]
  // to use proper JSON we need to temporarily unbind the site's toJSON functions
  const toJSON_OBJECTS = [Object, Array.prototype, Number.prototype, String.prototype];

  function delete_toJSONs() {
    const toJSON_originals = [];
    for (const obj of toJSON_OBJECTS) {
      if (obj.hasOwnProperty('toJSON')) {
        toJSON_originals.push({ obj, func: obj.toJSON });
        delete obj.toJSON;
      }
    }
    return toJSON_originals;
  }

  function restore_toJSONs(toJSON_originals) {
    for (const { obj, func } of toJSON_originals)
      obj.toJSON = func;
  }

  function JSON_stringify(obj, replacer, space) {
    let toJSON_originals;
    try {
      toJSON_originals = delete_toJSONs();
      return JSON.stringify(obj, replacer, space);
    } finally {
      restore_toJSONs(toJSON_originals);
    }
  }

  // enables JSON.stringify to stringify Sets
  function set_replacer(key, value) {
    if (typeof value === 'object' && value instanceof Set)
      return { t: 'Set', v: [...value] };
    return value;
  }

  // enables JSON.parse to parse Sets
  function set_reviver(key, value) {
    if (typeof value === 'object' && value.t === 'Set')
      return new Set(value.v);
    return value;
  }


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

  const IS_IDOL = (window.location.hostname === 'idol.sankakucomplex.com' ? 1 : 0);
  const HISTORY_KEY = (IS_IDOL ? 'view_history_idol' : 'view_history');
  const COMMON_TAGS_KEYS = ['common_tags_json', 'common_tags_json_idol'];
  const COMMON_TAGS_KEY = COMMON_TAGS_KEYS[IS_IDOL];
  const OTHER_COMMON_TAGS_KEY = COMMON_TAGS_KEYS[1 - IS_IDOL];

  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: 1,
    common_tags_json: DEFAULT_TAGLIST,
    common_tags_json_idol: '[ {"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\u00A0category", "tags":["t1 t2 t3"]} ]',
    sankaku_channel_dark_compatibility: false,
    view_history_enabled: false,
    view_history: new Set(),
    view_history_idol: new Set(),
    wiki_template: '',
  };

  const USE_MONKEY_STORAGE = IS_MONKEY;
  let USE_LOCAL_STORAGE;
  try {
    USE_LOCAL_STORAGE = !!localStorage.getItem;
  } catch (error) { // DOMException
    USE_LOCAL_STORAGE = false;
  }

  const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids

  const config = Object_clone(DEFAULT_CONFIG); // load default

  // applied to loaded/set config entries (e.g. config elements return strings when we need numbers)
  const CONFIG_FIXER = {
    scale_mode: Number,
    tag_menu_layout: Number,
  };

  function fix_config_entry(key, value) {
    const fixer = CONFIG_FIXER[key];
    return (fixer !== undefined ? fixer(value) : value);
  }

  // permanently save setting to localStorage (and broadcast to other tabs)
  function save_setting(key, value) {
    value = fix_config_entry(key, value);

    if (USE_MONKEY_STORAGE) {
      GM.setValue(key, JSON_stringify(value, set_replacer)).catch((reason) => {
        show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason);
      });
      return;
    }

    if (!USE_LOCAL_STORAGE) {
      show_notice(console.warn, `addon: couldn't save setting "${KEY_PREFIX + key}" to localStorage. check permissions`);
      return;
    }

    try {
      localStorage.setItem(KEY_PREFIX + key, JSON_stringify(value, set_replacer));
    } catch (error) {
      show_notice(console.error, `addon error: couldn't save setting "${KEY_PREFIX + key}" to localStorage, check console`, error);
    }
  }

  async function load_config() {
    const monkey_values = {};

    if (USE_MONKEY_STORAGE) {
      const promises = [];
      for (const key of Object.keys(config)) {
        promises.push(GM.getValue(key).then((value) => {
          monkey_values[key] = value;
        }));
      }

      await Promise.all(promises);
    }

    for (const key of Object.keys(config)) {
      let value = config[key]; // default already loaded

      let stored_value = monkey_values[key];

      if (stored_value === undefined && USE_LOCAL_STORAGE)
        stored_value = localStorage.getItem(KEY_PREFIX + key);

      if (stored_value !== undefined && stored_value !== null) {
        try {
          value = JSON.parse(stored_value, set_reviver);
        } catch (error) {
          show_notice(console.error, `addon error: couldn't load setting "${key}"`, error);
        }
      }

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

  function storage_changed(key, old_value, new_value, remote) {
    try {
      if (!remote) return; // only listen to other tabs

      if (new_value === undefined || new_value === null) {
        // entry was removed, reset setting to default
        update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
      } else {
        // entry was added or changed
        const value = JSON.parse(new_value, set_reviver);

        // workaround for post view history race condition
        if (key === HISTORY_KEY) {
          const new_ids = Set_difference(value, config[key]);
          if (new_ids.size === 0) return;

          // integrate newly received post ids into view history
          config[key] = Set_union(value, config[key]);

          // save new view history and broadcast it to other tabs,
          // which in turn might broadcast their ids back to us
          save_setting(key, value);

          // live update thumbnails
          if (!is_personal_post_page()) {
            for (const id of new_ids) {
              const thumbs = thumbnail_cache.get(id);
              if (thumbs === undefined) continue;

              for (const thumb of thumbs)
                fadeout_post(thumb);
            }
          }

          return; // don't call update_setting()
        }

        update_setting(key, value);
      }
    } catch (error) {
      show_notice(console.error, 'storage_changed() failed, check console', error);
    }
  }

  // localStorage from other tabs changed
  function local_storage_changed(e) {
    if (e.storageArea !== localStorage) return;
    if (e.key === null) return; // ignore external localStorage.clear() for now

    // only look at SankakuAddon specific changes
    if (!e.key.startsWith(KEY_PREFIX)) return;
    const key = e.key.substring(KEY_PREFIX.length);

    storage_changed(key, e.oldValue, e.newValue, true);
  }

  function update_setting(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();
    }

    update_config_dialog_by_key(key);

    if (key === 'hide_headerlogo') update_headerlogo();
  }

  function reset_setting(key) {
    if (USE_MONKEY_STORAGE) GM.deleteValue(key);
    if (USE_LOCAL_STORAGE) localStorage.removeItem(KEY_PREFIX + key); // also delete if USE_MONKEY_STORAGE
    update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
  }

  function reset_config() {
    for (const key of Object.keys(config)) {
      // don't clear the history so the clear history button makes more sense
      if (key === 'view_history' || key === 'view_history_idol') continue;
      // don't reset the common tags list of the other site
      if (key === OTHER_COMMON_TAGS_KEY) continue;

      reset_setting(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 ⏩)*'},
    view_history_enabled:               {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
    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_KEY]:                  {type: 'text',     desc: 'Common tags list (JSON format):'},
    tag_menu_layout:                    {type: 'select',   desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}},
    wiki_template:                      {type: 'text',     desc: 'Wiki template:', title: 'Text that will be be shown in a separate textarea on wiki add/edit pages so it can easily be copied'},
  };

  // 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 of Object.keys(config)) {
      const cfg_elem = document.getElementById(KEY_PREFIX + key);
      if (cfg_elem === null) continue;

      if (is_value_element(key)) f(cfg_elem, key, () => cfg_elem.value);
      else                       f(cfg_elem, key, () => cfg_elem.checked);
    }
  }

  function update_config_dialog_by_key(key) {
    const cfg_elem = document.getElementById(KEY_PREFIX + 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 of Object.keys(config)) 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 ? 'block' : 'none');
  }


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

  function set_cookie(name, value, valid_for_days = 365) {
    const date = new Date();
    date.setTime(date.getTime() + (valid_for_days * 24 * 60 * 60 * 1000));
    document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/`;
  }

  function get_cookie(name) {
    const cookies = document.cookie.split(';');
    for (const cookie of cookies) {
      const kv = cookie.split('=');
      if (kv.length === 2 && kv[0].trim() === name) {
        return decodeURIComponent(kv[1]);
      }
    }
    return '';
  }

  function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  // helper function to modify nodes on creation
  function register_observer(node_predicate, node_modifier) {
    const observer = new MutationObserver((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;
  }

  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 width_no_scroll = outer.offsetWidth;

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

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

    const width_with_scroll = inner.offsetWidth;

    outer.parentNode.removeChild(outer);

    return width_no_scroll - width_with_scroll;
  }

  // (almost) deepclone objects
  function Object_clone(obj) {
    if (typeof obj === 'object') {
      // shallow clone Arrays and Sets
      if (obj instanceof Array) return [...obj];
      if (obj instanceof Set) return new Set(obj);

      const new_obj = {};
      for (const [key, value] of Object.entries(obj))
        new_obj[key] = Object_clone(value);
      return new_obj;
    }

    return obj;
  }

  function Set_difference(a, b) {
    return new Set([...a].filter((x) => !b.has(x)));
  }

  function Set_union(a, b) {
    return new Set([...a, ...b]);
  }

  function show_notice(logFunc, ...msg) {
    if (unsafeWindow.notice) unsafeWindow.notice(msg[0]);
    if (logFunc) logFunc(...msg);
  }

  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], 10);
    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.push(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 = get_cookie('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);
  }


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

  const thumbnail_cache = new Map(); // id -> array of thumbnail elements
  let cached_search_tags = null;

  function get_search_tags() {
    if (cached_search_tags !== null) return cached_search_tags;

    const tags = new URL(window.location.href).searchParams.get('tags');
    cached_search_tags = (tags === null ? [] : tags.split(' '));

    return cached_search_tags;
  }

  // is own uploads or favorites page
  function is_personal_post_page() {
    const tags = get_search_tags();
    const username = get_username();
    return tags.includes('fav:' + username) || tags.includes('user:' + username);
  }

  function hide_headerlogo(hide) {
    const headerlogo = document.getElementById('headerlogo');
    if (headerlogo !== null) headerlogo.style.display = (hide ? 'none' : '');
  }

  function add_config_dialog() {
    const cfg_dialog = document.createElement('DIV');
    cfg_dialog.id = 'cfg_dialog';
    cfg_dialog.style.display = 'none';
    cfg_dialog.style.padding = '12px';
    cfg_dialog.style.border = '1px solid ' + shifted_backgroundColor(32);
    cfg_dialog.style.backgroundColor = get_original_background_color();
    // fixed, centered div
    cfg_dialog.style.top  = '50%';
    cfg_dialog.style.left = '50%';
    cfg_dialog.style.transform = 'translate(-50%, -50%)';
    cfg_dialog.style.position = 'fixed';
    cfg_dialog.style.zIndex = '10002';
    // scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins)
    cfg_dialog.style.maxWidth  = '90vw';
    cfg_dialog.style.maxHeight = '90vh';
    cfg_dialog.style.overflow = 'auto';

    // generate the content of the config menu
    let innerDivHTML = `<div style='font-weight: bold;'>SankakuAddon ${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 = () => `<span style="vertical-align: middle; ${value.title ? 'cursor:help; text-decoration: underline dashed; ' : ''}" `
        + `${value.title ? `title="${value.title}"` : ''} >${value.desc}</span>`;

      innerDivHTML += '<div style="vertical-align: middle;">';
      switch (type) {
        case 'checkbox':
          innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle;'>`;
          innerDivHTML += generate_span();
          // 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();
          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 += generate_span();
          innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" style='width: 100%; box-sizing: border-box'></textarea>`;
          break;
        default:
          show_notice(console.error, 'addon error: CONFIG_TEMPLATE is defective!', type);
          continue;
      }
      innerDivHTML += '</div>';
    }

    innerDivHTML += '<div>';
    innerDivHTML +=  '<button id="config_close" style="cursor: pointer;">Close</button>';
    innerDivHTML +=  '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>';
    innerDivHTML +=  '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>';
    innerDivHTML += '</div>';
    innerDivHTML += '<div>&nbsp;*requires a page reload.</div>';

    cfg_dialog.innerHTML = innerDivHTML;

    document.body.appendChild(cfg_dialog);

    // add events
    document.getElementById('config_close').onclick = () => { show_config_dialog(false); return false; };
    document.getElementById('config_reset').onclick = () => { reset_config(); return false; };
    document.getElementById('history_clear').onclick = () => { reset_setting(HISTORY_KEY); return false; };

    foreach_config_element((cfg_elem, key, get_value) => {
      cfg_elem.addEventListener('change', () => {
        update_setting(key, get_value());
        save_setting(key, get_value());
      });
    });
  }

  function add_config_button() {
    const navbar = document.getElementById('navbar');
    if (navbar === null) return;

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

    const lang_select = navbar.querySelector('.lang-select');
    if (lang_select !== null)
      lang_select.style.borderRight = 0; // prevent config button from jumping a pixel on mouseover

    const a = document.createElement('A');
    a.href = '#';
    a.onclick = () => { 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.innerText.replace(/ /g, '_'); // hopefully this is the only edgecase

      // generates click listeners
      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.trim() + ' ' + tagname;
          } 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.innerText = '+';
        a.onclick = tag_search_button_func(tagname);

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

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

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

  function modify_thumbnails(root) {
    if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return;
    if (root === null) return;

    for (const thumb of root.getElementsByClassName('thumb')) {
      // read thumbnail/post id
      const pid = thumb.id;
      if (typeof pid !== 'string' || !pid.startsWith('p')) {
        show_notice(console.error, 'addon error: thumbnail doesn\'t have an id?!', thumb);
        return;
      }

      const id = Number(pid.substring(1));
      if (Number.isNaN(id)) {
        show_notice(console.error, 'addon error: thumbnail id isn\'t a number?!', id);
        return;
      }

      // use and update thumbnail_cache
      let thumbs = thumbnail_cache.get(id);
      if (thumbs === undefined) thumbs = [];

      const is_new = !thumbs.includes(thumb);

      if (is_new) thumbs.push(thumb);
      thumbnail_cache.set(id, thumbs);

      if (is_new) {
        add_speaker_icon(thumb, id);

        if (!is_personal_post_page())
          fadeout_viewed_post(thumb, id);
      }
    }
  }

  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.includes('has_audio'))) {
      icon.innerText = '🔊';
    } else if (config.show_animated_icon && (tags.includes('animated') || tags.includes('video'))) {
      icon.innerText = '⏩';
    } 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);
  }

  // TODO this isn't optimal when the thumbnail has low contrast to the background
  function fadeout_post(thumb_span) {
    thumb_span.style.opacity = '20%';
  }

  function fadeout_viewed_post(thumb_span, id) {
    if (config[HISTORY_KEY].has(id))
      fadeout_post(thumb_span);
  }

  function add_thumbnail_observer(predicate) {
    // don't hog CPU when disabled, but requires page reload to activate
    if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return;

    // this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
    register_observer(predicate, (node, observer) => {
      modify_thumbnails(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 called_add_mode_options = false;
  let call_postmodemenu_init_workaround = false;
  let cached_username = null;

  // won't work on any page
  function get_username() {
    if (cached_username !== null) return cached_username;

    // read from the 'My Favorites' button (in one of the subnavs) in the navbar
    const navbar = document.getElementById('navbar');
    if (navbar === null) return null;

    const fav_prefix = window.location.origin + '/?tags=fav%3A';

    for (const a of navbar.getElementsByTagName('A')) {
      if (typeof a.href === 'string' && a.href.startsWith(fav_prefix)) {
        cached_username = a.href.substring(fav_prefix.length);
        break;
      }
    }

    return cached_username;
  }

  // this won't be called when not logged in or when the user does not have tag script permission
  function add_mode_options() {
    if (called_add_mode_options) return;
    called_add_mode_options = true;

    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.log, 'addon error: add_mode_options() couldn\'t find mode dropdown?!');
      return;
    }

    // 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_init_workaround) {
      PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
    }
  }

  function add_postmode_hotkeys() {
    document.addEventListener('keydown', (e) => {
      const mode_dropdown = document.getElementById('mode');
      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;
        }
      }

      const old_mode = mode_dropdown.value;

      switch (e.key) {
        case 'v':
          if (IS_GREASEMONKEY4) {
            show_notice(console.warn, 'addon: \'Set Parent\' not yet supported in Greasemonkey');
            return;
          }

          mode_dropdown.value = 'set-parent';
          break;
        case 'c':
          if (IS_GREASEMONKEY4) {
            show_notice(console.warn, 'addon: \'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;
        default:
          return;
      }

      // couldn't set mode (option doesn't exist)
      if (!mode_dropdown.value) mode_dropdown.value = old_mode;

      PostModeMenu_change_override();
    }, true);
  }

  function PostModeMenu_init_workaround() {
    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.error, 'addon error: PostModeMenu_init_workaround() couldn\'t find mode dropdown?!');
      return;
    }

    // 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 = get_cookie('addon_mode');
    if (mode !== '') {
      set_cookie('mode', mode, 7);
      mode_dropdown.value = mode;

      // setting mode failed, possible when changing to account with lower permissions
      if (!mode_dropdown.value) {
        show_notice(console.error, `addon error: couldn't set mode to ${mode}, resetting to 'view'`);
        mode_dropdown.value = 'view';
        set_cookie('mode', 'view', 7);
      }
    }

    PostModeMenu_change_override();
  }

  async function PostModeMenu_change_override() {
    const mode_dropdown = document.getElementById('mode');
    if (mode_dropdown === null) {
      show_notice(console.error, 'addon error: PostModeMenu_change_override() couldn\'t find mode dropdown?!');
      return;
    }

    if (!mode_dropdown.value) {
      show_notice(console.error, 'addon error: invalid mode, resetting to \'view\'');
      mode_dropdown.value = 'view';
      set_cookie('mode', 'view', 7);
    }

    const s = mode_dropdown.value;

    // try to guarantee sitescript has loaded
    while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined)
      await sleep(100);

    unsafeWindow.PostModeMenu.change();

    set_cookie('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 (get_cookie('chosen-parent') === '') {
        show_notice(console.warn, '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 (PostModeMenu_click_original(post_id))
      return true; // view mode, let it click

    const mode_dropdown = document.getElementById('mode');
    if (!called_add_mode_options) return false; // not logged in or no tag script permission

    const s = mode_dropdown.value;
    if (s === 'choose-parent') {
      set_cookie('chosen-parent', post_id);
      mode_dropdown.value = 'set-parent';
      PostModeMenu_change_override();
    } else if (s === 'set-parent') {
      const parent_id = get_cookie('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_setting('tag_menu_scale', yPercentfromBottom);
  }


  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 = '<div style="width: calc(100% - 2px); height: 100%; overflow: auto;"><span id="common_tags"></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.top   = '0';
    tag_menu_close.style.right = '0';
    tag_menu_close.onclick = () => { 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 = () => { 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.top   = '0';
    tag_menu_save.style.right = '36px';
    tag_menu_save.style.width = '140px';
    tag_menu_save.style.fontWeight = 'bold';
    tag_menu_save.addEventListener('click', (e) => {
      e.preventDefault();
      if (tags_submit_listener())
        document.getElementById('edit-form').submit();
    });
    tag_menu.appendChild(tag_menu_save);
  }

  function update_tag_menu(skip_common_tags = false) {
    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, skip_common_tags_update = false) {
      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() {
        if (tag_is_present(tag)) {
          remove_tag(tag, skip_common_tags_update);
          a.className = 'tag_nonexistent';
        } else {
          add_tag(tag, skip_common_tags_update);
          a.className = '';
        }
        return false;
      };
      a.innerText = 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) {
      const div = create_top_level_div();
      div.appendChild(create_tag_button(current_tag, false));
      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);

    // don't rebuild the common tags list when common tags buttons are pressed
    if (skip_common_tags) return;

    // now add common tags
    // common_tags_json(_idol) 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_KEY]);
    } catch (error) {
      show_notice(console.error, 'addon error: "common tags" JSON syntax error', error);
      return;
    }

    if (!Array.isArray(tag_data)) {
      show_notice(console.error, '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(console.error, '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(console.error, '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(console.error, '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(console.error, '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], true), '1px'));
              tr.appendChild(td);
            }
            table.appendChild(tr);
          }

          const 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(console.error, 'addon error: "common tags" "tags" array contains a group with not exactly 1 tags string');
            return;
          }

          const 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, true), '3px'));

          const 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)>
          const 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, true)));
            list_flex.appendChild(div);
          }
        }
      }

      const span = document.createElement('SPAN');
      span.innerText = (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', () => {
      tags_changed = true;
      clearTimeout(tag_update_timer);
      tag_update_timer = setTimeout(update_tag_elements, 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', (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;
    if (actions_ul === null) return;

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

  function add_addon_actions() {
    if (actions_ul === null) {
      show_notice(console.error, 'addon error: couldn\'t find actions list! Disabled addon actions.');
      return;
    }

    const separator = document.createElement('H5');
    separator.innerText = '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 = () => { func(); return false; };
      a.innerText = name;

      const li = document.createElement('LI');
      li.id = id;
      li.appendChild(a);
      actions_ul.appendChild(li);
    };

    add_action(() => { scale_image(SCALE_MODES.FIT,        true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image',              'scale-image-fit');
    add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor');
    add_action(() => { scale_image(SCALE_MODES.VERTICAL,   true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)',   'scale-image-ver');
    add_action(() => { scale_image(SCALE_MODES.RESET,      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(console.error, 'addon error: couldn\'t find post id?! Flag duplicate feature disabled.');
      return;
    }

    add_action(() => { flag_duplicate(post_id, '');                       }, 'Flag duplicate',              'flag-duplicate');
    add_action(() => { flag_duplicate(post_id, ', visually identical');   }, 'Flag duplicate (identical)',  'flag-duplicate-identical');
    add_action(() => { flag_duplicate(post_id, ' with worse quality');    }, 'Flag duplicate (quality)',    'flag-duplicate-quality');
    add_action(() => { 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.innerText = 'Clear';
      el.onclick = () => { 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.innerText = 'Reset';
      el.onclick = () => { 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.innerText = 'Reset';
      el.onclick = () => { 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.includes('duplicate')) {
      dup_button.onclick = function() { add_tag('duplicate'); return false; };
      dup_button.innerText = 'Tag duplicate';
    } else {
      dup_button.onclick = function() { remove_tag('duplicate'); return false; };
      dup_button.innerText = 'Untag duplicate';
    }

    if (!tags.includes('legitimate_variation')) {
      var_button.onclick = function() { add_tag('legitimate_variation'); return false; };
      var_button.innerText = 'Tag legitimate_variation';
    } else {
      var_button.onclick = function() { remove_tag('legitimate_variation'); return false; };
      var_button.innerText = 'Untag legitimate_variation';
    }

    pot_button.innerText = 'Untag potential_duplicate';
    if (!tags.includes('potential_duplicate')) {
      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, skip_common_tags_update = false) {
    const tags = get_tags_array();

    if ((tag === 'duplicate' && tags.includes('legitimate_variation')) || (tag === 'legitimate_variation' && tags.includes('duplicate'))) {
      show_notice(console.warn, 'addon: cannot tag as duplicate and legitimate_variation at the same time.');
      return;
    }

    if (tags.includes(tag)) {
      show_notice(console.warn, 'addon: tag already present');
      return;
    }

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

    tags_changed = true;
    update_tag_elements(skip_common_tags_update);
  }

  function remove_tag(tag, skip_common_tags_update = false) {
    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;
    update_tag_elements(skip_common_tags_update);
  }

  function delete_useless_tags_tag() {
    if (tags_changed && config.editform_deleteuselesstags) remove_tag('useless_tags');
  }

  function tag_is_present(tag) {
    return get_tags_array().includes(tag);
  }

  function reset_tags() {
    document.getElementById('post_tags').value = document.getElementById('post_old_tags').value;
    tags_changed = false;
    update_tag_elements();
  }

  function update_tag_elements(skip_common_tags = false) {
    update_tag_buttons();

    const tag_menu = document.getElementById('tag_menu');
    if (tag_menu !== null && tag_menu.style.display !== 'none') {
      update_tag_menu(skip_common_tags);
    }
  }



  // flag option with default text
  function flag_duplicate(id, reason_suffix) {
    if (IS_GREASEMONKEY4) {
      show_notice(console.warn, 'addon: \'Flag duplicate\' not yet supported in Greasemonkey');
      return;
    }

    if (parent_id === null) {
      show_notice(console.warn, 'addon: parent id not found, not logged in?');
      return;
    }

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

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

    const tags = get_tags_array();
    const old_tags = get_old_tags_array();
    if (tags.includes('duplicate') && !old_tags.includes('duplicate')) {
      show_notice(console.warn, 'addon: duplicate tag set but not saved!');
      return;
    }
    if (!old_tags.includes('duplicate')) {
      show_notice(console.warn, 'addon: not tagged as duplicate!');
      return;
    }

    if (old_tags.includes('legitimate_variation') || old_tags.includes('revision'))
      if (!window.confirm('Post is tagged as a legitimate_variation or revision, it may not be a duplicate!\n\nFlag it anyway?'))
        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, reason },
      onComplete(response) {
        const resp = response.responseJSON;
        if (resp.success) show_notice(console.log, 'Post was resent to moderation queue');
        else              show_notice(console.error, `Error: ${resp.reason}`);
      }
    });
  }


  function read_image_data() {
    const data = {
      img_elem: null, // <img>, <video> or <object> (in case of flash)
      emb_elem: null, non_img_div: null, // flash is <object><embed>, we need the <div> it's in as well
      is_flash: false,
      width: null,
      height: null,
      aspect_ratio: null,
      current_height: null // store current height separately, because flash is a bitch
    };

    // image or video
    const img = document.getElementById('image');
    if (img !== null) {
      data.img_elem = img;

      // 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.innerText.split('x'); // parse "<width>x<height>"
      } else {
        const highres = document.getElementById('highres');
        if (highres !== null) {
          res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)"
        }
      }

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

      data.width  = res[0];
      data.height = res[1];
      data.aspect_ratio = data.width / data.height;
      return data;
    }

    // flash or unknown
    const non_img = document.getElementById('non-image-content');
    if (non_img !== null) {
      data.non_img_div = non_img;

      const objs = non_img.getElementsByTagName('OBJECT');
      const embs = non_img.getElementsByTagName('EMBED');
      data.is_flash = (objs.length === 1 && embs.length === 1); // <object><embed>

      if (!data.is_flash) {
        show_notice('addon error: unknown post content! Can\'t read width/height.');
        return null;
      }

      data.img_elem = objs[0];
      data.emb_elem = embs[0];
      // <object> contains width/height in both Firefox and Chrome
      data.width  = data.img_elem.width;
      data.height = data.img_elem.height;
      data.current_height = data.height;
      data.aspect_ratio = data.width / data.height;
      return data;
    }

    return null;
  }

  const SCALE_MODES = { RESET: -1, FIT: 0, HORIZONTAL: 1, VERTICAL: 2 };

  // stretch image/video/flash, requires data from read_image_data()
  function scale_image(mode, always_scale) {
    if (image_data === null) return; // read_image_data() failed

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

    const note_container = document.getElementById('note-container');

    // reset image size
    if (mode === SCALE_MODES.RESET) {
      if (!image_data.is_flash) {
        image_data.img_elem.style.width = null;
        image_data.img_elem.style.height = null;
        if (note_container !== null) note_container.style.transform = '';
      } 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;
    }

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

    const target_w = Math.max(window.innerWidth - left_side - get_scrollbar_width() - 1, 1);
    const target_h = Math.max(window.innerHeight - 1, 1);
    const target_aspect_ratio = target_w / target_h;

    if (mode === SCALE_MODES.FIT)
      mode = (image_data.aspect_ratio > target_aspect_ratio ? SCALE_MODES.HORIZONTAL : SCALE_MODES.VERTICAL);

    let new_width, new_height;
    if (mode === SCALE_MODES.HORIZONTAL) {
      new_width = Math.floor(target_w);
      new_height = Math.floor(target_w / image_data.aspect_ratio);
    } else if (mode === SCALE_MODES.VERTICAL) {
      new_width = Math.floor(target_h * image_data.aspect_ratio);
      new_height = Math.floor(target_h);
    }

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


    // We sadly can't use transform scale here because it doesn't change the DOM size,
    // so the post content would cover up (or be covered up by) the elements below it
    const set_dimensions = (obj) => {
      obj.width  = new_width  + 'px';
      obj.height = new_height + 'px';
    };

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

      // For notes we need to use transform scale because style.width/height/top/left
      // is exactly what gets stored on the server
      if (note_container !== null) {
        note_container.style.transformOrigin = '0 0';
        note_container.style.transform = `scale(${new_width / image_data.width})`;
      }
    }

    image_data.current_height = new_height;
  }

  function scale_on_resize_helper() {
    clearTimeout(resize_timer);
    resize_timer = setTimeout(() => {
      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_div : 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);
    }
  }

  // when resize notice is hidden (e.g. original image is loaded), scroll to make up the difference
  function add_resize_notice_listener() {
    const resized_notice = document.getElementById('resized_notice');
    if (image_data === null || resized_notice === null) return;

    const notice_y_diff = image_data.img_elem.getBoundingClientRect().top - resized_notice.getBoundingClientRect().top;

    const observer = new MutationObserver(() => {
      observer.disconnect();
      window.scrollBy(0, -notice_y_diff);
    });
    observer.observe(resized_notice, {
      attributes: true,
      attributeFilter: ['style'],
    });
  }

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

  function add_postpage_hotkeys() {
    document.addEventListener('keydown', (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(SCALE_MODES.RESET, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'f': // f(it)
          scale_image(SCALE_MODES.FIT, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'g': // g
          scale_image(SCALE_MODES.HORIZONTAL, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'h': // h
          scale_image(SCALE_MODES.VERTICAL, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 's': // s(imilar)
          if (post_id === null) {
            show_notice(console.error, 'addon error: couldn\'t find post id?!');
          } else {
            open_in_tab(window.location.origin + '/post/similar?id=' + post_id);
          }
          break;
        case 'd': // d(elete)
          if (post_id === null) {
            show_notice(console.error, 'addon error: couldn\'t find post id?!');
          } else if (!found_delete_action) {
            show_notice(console.error, 'addon error: Delete action not found, no permission?');
          } else {
            open_in_tab(window.location.origin + '/post/delete/' + post_id);
          }
          break;
      }
    }, true);
  }

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

  function add_dtext_style_buttons() {
    const wiki_form = document.getElementById('wiki-form');
    const wiki_body = document.getElementById('wiki_page_body');

    if (wiki_form === null || wiki_body === null) {
      show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", DText style buttons disabled');
      return;
    }

    wiki_form.style.display = 'flex'; // add buttons to the right

    const div = document.createElement('DIV');
    div.style.marginLeft = '1em';
    div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top) + 'px';
    div.style.background = get_original_background_color();

    div.innerHTML = '<label title="Buttons add (or remove outermost) DText styling on selected text.\n'
      + 'Buttons have accesskeys/keyboard shortcuts, typically activated by Alt + Shift + key or Alt + key depending on the browser."'
      + ' style="cursor:help;">DText style buttons</label><br>';

    const DTEXT_CODES = [
      'b', 'i', 's', 'u', '<br>',
      'code', 'quote', 'spoiler', '<br>',
      '[[...]]', '{{...}}', 'URL',
    ];

    for (const code of DTEXT_CODES) {
      if (code === '<br>') {
        div.appendChild(document.createElement('BR'));
        continue;
      }

      const btn = document.createElement('BUTTON');
      btn.style.cursor = 'pointer';
      btn.onclick = () => {
        const text = wiki_body.value;
        const a = wiki_body.selectionStart;
        const b = wiki_body.selectionEnd;
        const selection = text.substring(a, b);

        let cut_a = a, cut_b = b;         // range to cut
        let insert;                       // text to insert inbetween
        let sel_off_a = 0, sel_off_b = 0; // by default the inserted text is selected, these are offsets

        if (code === 'URL') {
          const link = window.prompt('Enter URL');
          if (link === null) return false;
          insert = `"${selection}":${link}`;
        } else {
          let open  = '';
          let close = '';
          if (code === '{{...}}') {
            open  = '{{';
            close = '}}';
          } else if (code === '[[...]]') {
            open  = '[[';
            close = ']]';
          } else {
            open  = `[${code}]`;
            close = `[/${code}]`;
          }

          const ext_a = Math.max(0, a - open.length);
          const ext_b = b + close.length;
          const ext_selection = text.substring(ext_a, ext_b);

          if (ext_selection.startsWith(open) && ext_selection.endsWith(close)) {
            // remove DText directly outside selection
            insert = selection;
            cut_a = ext_a;
            cut_b = ext_b;
            sel_off_a = -open.length;
          } else if (selection.startsWith(open) && selection.endsWith(close)) {
            // remove DText directly inside selection
            insert = selection.slice(open.length, -close.length);
          } else {
            let cleaned;

            const open_i  = selection.indexOf(open);
            const close_i = selection.lastIndexOf(close);

            // remove existing (inner) DText before adding new one
            if (open_i !== -1 && close_i > open_i) {
              cleaned = selection.substring(0, open_i)
                + selection.substring(open_i  + open.length, close_i)
                + selection.substring(close_i + close.length);
            } else {
              cleaned = selection;
            }

            insert = open + cleaned + close;
            // select 'cleaned'
            sel_off_a = open.length;
            sel_off_b = -(open.length + close.length);
          }
        }

        const sel_a = a + sel_off_a;
        const sel_b = sel_a + insert.length + sel_off_b;

        wiki_body.value = text.substring(0, cut_a) + insert + text.substring(cut_b);
        wiki_body.setSelectionRange(Math.max(0, sel_a), sel_b);
        wiki_body.focus();
        return false;
      };

      // add accesskeys and titles
      if (code.length === 1) {
        btn.accessKey = code;
        btn.title =
          code === 'b' ? 'bold' :
          code === 'i' ? 'italic' :
          code === 's' ? 'strikethrough' :
          code === 'u' ? 'underline' : '';
      } else {
        btn.accessKey =
          code === 'code'    ? 'c' :
          code === 'quote'   ? 'q' :
          code === 'spoiler' ? 'o' :
          code === '[[...]]' ? 'w' :
          code === '{{...}}' ? 'p' :
          code === 'URL'     ? 'l' : '';

        if (code === '[[...]]') btn.title = 'accesskey: w';
        if (code === '{{...}}') btn.title = 'accesskey: p';
      }

      const u = // character to underline
        code === 'code'    ? 0 :
        code === 'quote'   ? 0 :
        code === 'spoiler' ? 2 :
        code === 'URL'     ? 2 :
        code.length === 1  ? 0 : -1;

      if (u !== -1) {
        btn.innerHTML = `${code.substring(0, u)}<span style="text-decoration: underline;">${code[u]}</span>${code.substring(u + 1)}`;
      } else {
        btn.innerText = code;
      }

      div.appendChild(btn);
    }

    wiki_form.appendChild(div);
  }

  function add_wiki_template() {
    if (config.wiki_template.length === 0) return;

    const wiki_form = document.getElementById('wiki-form');
    const wiki_body = document.getElementById('wiki_page_body');

    if (wiki_form === null || wiki_body === null) {
      show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", wiki template disabled');
      return;
    }

    wiki_form.style.display = 'flex'; // add template to the right

    const div = document.createElement('DIV');
    div.style.marginLeft = '1em';

    const template_label = document.createElement('LABEL');
    template_label.innerText = 'Wiki Template';
    template_label.style.cursor = 'help';
    template_label.style.textDecoration = 'underline dashed';
    template_label.title = 'Selected text can be appended to the page body. Clicking or using arrow keys selects a whole line, pressing \'c\' or the button below copies the selection over';

    const template_text = document.createElement('TEXTAREA');
    template_text.id = 'wiki_template_text';
    template_text.cols = wiki_body.cols;
    template_text.rows = wiki_body.rows;
    template_text.style.width = '33em';
    template_text.value = config.wiki_template;

    const insert_template_selection = () => {
      const text = template_text.value;
      const a = template_text.selectionStart;
      const b = template_text.selectionEnd;

      const selection = text.substring(a, b);
      const add_newline = wiki_body.value && !wiki_body.value.endsWith('\n');
      wiki_body.value += (add_newline ? '\n' : '') + selection;
    };

    const extend_selection = () => {
      // extend empty selection to newlines (or text start/end)
      const text = template_text.value;
      let a = template_text.selectionStart;
      let b = template_text.selectionEnd;

      if (a === b) {
        const ext_a = text.lastIndexOf('\n', a - 1);
        a = (ext_a !== -1 ? ext_a + 1 : 0);

        if (text.charAt(b) !== '\n') {
          const ext_b = text.indexOf('\n', b + 1);
          b = (ext_b !== -1 ? ext_b - 1 : text.length - 1) + 1;
        }

        template_text.setSelectionRange(a, b);
      }
    };

    template_text.readOnly = true; // hides the caret and there's no easy workaround
    template_text.addEventListener('click', extend_selection);
    template_text.addEventListener('keyup', extend_selection);
    template_text.addEventListener('keydown', (e) => {
      if (e.ctrlKey || e.altKey || e.shiftKey) return;
      if (e.key === 'c') insert_template_selection();
    });

    const btn = document.createElement('BUTTON');
    btn.innerText = 'Copy selection over';
    btn.style.cursor = 'pointer';
    btn.style.fontWeight = 'bold';
    btn.style.padding = '0.2em 2em';
    btn.style.margin = '0.1em';
    btn.onclick = () => { insert_template_selection(); template_text.focus(); return false; };

    div.appendChild(template_label);
    div.appendChild(document.createElement('BR'));
    div.appendChild(template_text);
    div.appendChild(document.createElement('BR'));
    div.appendChild(btn);
    wiki_form.appendChild(div);

    div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top - template_label.getBoundingClientRect().height) + 'px';
  }

  function add_tag_edit_gear() {
    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.innerText = '⚙';
      wiki_edit_link.title = 'Edit Tag';
      h2.appendChild(wiki_edit_link);
    } catch (error) {
      show_notice(console.error, 'addon error: couldn\'t add "⚙" tag page link, check console', error);
    }
  }


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

  await load_config();

  // listen for config changes in other windows
  if (USE_MONKEY_STORAGE) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
  else if (USE_LOCAL_STORAGE) window.addEventListener('storage', local_storage_changed);
  else show_notice(console.error, 'addon error: couldn\'t add storage change listener! No cross-tab communication possible.');

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

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

  if (pathname === '/' || pathname === '/post/index' || 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((node) => {
      return (node.value === 'apply-tag-script');
    }, (node, observer) => {
      observer.disconnect();

      // don't pass node.parentNode, because it can be undefined or the <div> above for reasons I cannot understand
      add_mode_options();
      return true;
    });

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

    // add thumbnail icons and fade out thumbnails for dynamically loaded posts (from auto paging)
    add_thumbnail_observer((node) => (node.classList != null && node.classList.contains('content-page')));


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

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

    // mute/pause videos
    const observer = register_observer((node) => {
      return (node.id === 'image');
    }, (node, observer) => {
      configure_video(node);
      return true;
    });

    const video = document.getElementById('image');
    if (video !== null) {
      observer.disconnect();
      configure_video(video);
    }

    add_thumbnail_observer((node) => (node.id === 'recommendations'));

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

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

    add_thumbnail_observer((node) => (node.id === 'recommendations'));

  }




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

  async function init() {
    // 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 css = '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;}'
      + ' a.tag_nonexistent { color: #E00; }'; // custom style for tag menu
    add_style(css);

    add_config_dialog();
    if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C');
    add_config_button();
    update_config_dialog();

    update_headerlogo();

    // process what the thumbnail observer may have missed
    modify_thumbnails(document);

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

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

      if (config.tag_search_buttons) add_tag_search_buttons();

      add_postmode_hotkeys();

      if (called_add_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_init_workaround = true;
      }

      if (!IS_GREASEMONKEY4) {
        // try to guarantee sitescript has loaded
        while (unsafeWindow.PostModeMenu === undefined)
          await sleep(100);

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


    /*************/
    /* 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.innerText;
      } else {
        const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
        post_id = temp.substring(temp.lastIndexOf('/') + 1);
      }

      post_parent_id = document.getElementById('post_parent_id');
      if (post_parent_id !== null)
        parent_id = post_parent_id.value;

      if (config.view_history_enabled) {
        config[HISTORY_KEY].add(Number(post_id));
        save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
      }

      if (config.tag_search_buttons) add_tag_search_buttons();

      find_actions_list();
      add_addon_actions();

      add_tag_buttons();
      if (config.tag_menu) add_tag_menu();
      update_tag_elements(); // initialize tag menu/buttons
      add_tags_change_listener();
      add_tags_submit_listener();

      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);
      add_resize_notice_listener();
      if (config.sankaku_channel_dark_compatibility) note_fix();
      add_postpage_hotkeys();
      if (config.scale_on_resize) add_scale_on_resize_listener();



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

    } else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) {

      add_dtext_style_buttons();
      add_wiki_template();

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

      add_tag_edit_gear();

    }

  }

  if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
    init().catch((reason) => {
      show_notice(console.error, 'addon error: init() failed, check console', reason);
    });
  } else {
    document.addEventListener('DOMContentLoaded', init, false);
  }
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);