SankakuAddon

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

As of 2020-12-12. See the latest version.

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

(function(unsafeWindow) {
  'use strict';

  const VERSION = 'v0.99.19';

  // 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 console_log(...args) {
    if (typeof window.console === 'undefined') return;

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

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

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

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

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

  const DEFAULT_CONFIG = {
    scroll_to_image: true,
    scale_image: true, // and video
    scale_only_downscale: false,
    scale_flash: false,
    scale_mode: 0,
    scale_on_resize: false,
    scroll_to_image_center: true,
    video_pause: false,
    video_mute: true,
    set_video_volume: false,
    video_volume: 50,
    video_controls: true,
    show_speaker_icon: true,
    show_animated_icon: true,
    setparent_deletepotentialduplicate: false,
    editform_deleteuselesstags: false,
    hide_headerlogo: false,
    tag_search_buttons: true,
    tag_menu: true,
    tag_menu_scale: '30%',
    tag_menu_layout: 1,
    common_tags_json: DEFAULT_TAGLIST,
    sankaku_channel_dark_compatibility: false,
  };

  let USE_LOCAL_STORAGE;
  try {
    USE_LOCAL_STORAGE = !!localStorage.getItem;
  } catch /* DOMException */ {
    USE_LOCAL_STORAGE = false;
  }

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

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

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

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

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

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

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

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

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

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

      config_changed(key, value);
    }
  }

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

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

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

    update_config_dialog_by_key(key);

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

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

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


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

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

    const type = CONFIG_TEMPLATE[key].type;
    return (type === 'select' || type === 'text');
  }

  // calls f(cfg_elem, key, get_value) for each existing config element
  function foreach_config_element(f) {
    for (const key in config) {
      if (config.hasOwnProperty(key)) {
        const cfg_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 in config) {
      if (config.hasOwnProperty(key)) {
        update_config_dialog_by_key(key);
      }
    }
  }

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

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


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

  function 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.toGMTString() + "; path=/";
  }

  function get_cookie(name) {
    const cookies = document.cookie.split(';');
    for (let 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;
  }

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

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

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

    return (avg <= 128);
  }

  function rgb_array_shift(rgb, shift) {
    const shifted = [];
    for (let i = 0; i < 3; i++)
      shifted.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 functions */
  /*********************/

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


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

    const visible = (headerlogo.style.display !== 'none');

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

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

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

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

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

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

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

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

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

    innerDiv.innerHTML = innerDivHTML;

    document.body.appendChild(cfg_dialog);

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

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

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

    const 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 = function() { show_config_dialog(true); return false; };
    a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config';
    a.style.fontSize = '120%';

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

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

    const items = tagsidebar.getElementsByTagName('LI');
    for (const item of items) {
      const taglink = item.getElementsByTagName('A')[0];
      const tagname = taglink.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 + ' ' + tagname).trim();
          } else {
            search_tags.splice(tag_index, 1);
            search_field.value = search_tags.join(' ');
          }

          search_field.focus({preventScroll: true});

          return false;
        };
      };

      {
        const a = document.createElement('A');
        a.href = '#';
        a.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 add_speaker_icons(root) {
    if (!(config.show_speaker_icon || config.show_animated_icon)) return;
    if (root === null) return;

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

  function add_speaker_icon(thumb_span) {
    const img = thumb_span.querySelector('.preview');
    if (img === null) return;
    const a = thumb_span.getElementsByTagName('A');
    if (a.length === 0) return;
    if (thumb_span.getElementsByClassName('speaker_icon').length > 0) return; // already has speaker icon

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

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

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

    // this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
    register_observer(predicate, (node, observer) => {
      add_speaker_icons(node);
      return false; // listen forever
    });
  }

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

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


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

  let mode_dropdown = null;
  let added_mode_options = false;
  let call_postmodemenu_change = false;

  function add_mode_options(dropdown) {
    if (added_mode_options) return;
    added_mode_options = true;
    mode_dropdown = dropdown ? dropdown : document.getElementById('mode');

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

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

      if (mode_dropdown.options.namedItem('set-parent') === null) {
        const option = document.createElement('option');
        option.text  = 'Set Parent';
        option.value = 'set-parent';
        mode_dropdown.add(option);
      }
    }

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

  function add_postmode_hotkeys() {
    document.addEventListener('keydown', (e) => {
      if (mode_dropdown === null) return;
      if (e.ctrlKey || e.altKey || e.shiftKey) return;

      if (e.target === mode_dropdown) {
        e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
      } else {
        const tag = e.target.tagName.toLowerCase();
        if (tag === 'input' || tag === 'textarea' || tag === 'select') {
          return;
        }
      }

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

          mode_dropdown.value = 'set-parent';
          break;
        case 'c':
          if (is_greasemonkey4) {
            show_notice('addon error: \'Choose Parent\' not yet supported in Greasemonkey');
            return;
          }

          mode_dropdown.value = 'choose-parent';
          break;
        case 'q':
          mode_dropdown.value = 'rating-q';
          break;
        case 's':
          mode_dropdown.value = 'rating-s';
          break;
        case 'e':
          mode_dropdown.value = 'rating-e';
          break;
      }

      PostModeMenu_change_override();
    }, true);
  }

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

    const mode = get_cookie('addon_mode');
    if (mode !== '') {
      set_cookie('mode', mode, 7);
      mode_dropdown.value = mode;
    }

    PostModeMenu_change_override();
  }

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

    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('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

    if (!added_mode_options) return false; // not logged in

    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_config('tag_menu_scale', yPercentfromBottom, false);
  }


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

    const tag_menu = document.createElement('DIV');
    tag_menu.id = 'tag_menu';
    tag_menu.style.display = 'none';
    tag_menu.style.width = '100%';
    tag_menu.style.height = config.tag_menu_scale;
    tag_menu.style.position = 'fixed';
    tag_menu.style.bottom = '0';
    tag_menu.style.overflow = 'auto';
    tag_menu.style.backgroundColor = get_original_background_color();
    tag_menu.style.zIndex = '10001';
    document.body.appendChild(tag_menu);

    // the inner div ensures tag_menu_close button doesn't scroll with the content
    tag_menu.innerHTML = '<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 should be an array of objects with an optional string "name" field and an array "tags" field,
    // where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group)
    // or arrays of array containing one string (representing a table)
    // ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
    let tag_data;
    try {
      tag_data = JSON.parse(config.common_tags_json);
    } catch (error) {
      show_notice('addon error: "common tags" JSON syntax error');
      return;
    }

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

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

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

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

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

      const group_style = function(el) {
        // red in darkmode needs more contrast
        const rgb = rgb_to_array(get_original_background_color());
        if (is_darkmode()) {
          el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, 96));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64));
        } else {
          el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, -64));
          el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32));
        }
      };

      for (const list_tag of list_tags) {
        const is_array = Array.isArray(list_tag);

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

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

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

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

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

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

          // div (flexbox)><div><table><tr><td><div (button)>
          const table = document.createElement('TABLE');
          table.style.display = 'inline-block';
          group_style(table);
          table.style.marginBottom = '0';
          for (let row = 0; row < table_height; row++) {
            const tr = document.createElement('TR');
            for (let col = 0; col < table_width; col++) {
              const td = document.createElement('TD');
              td.style.border = 'none';
              td.style.padding = '0';
              if (tags_table[row][col])
                td.appendChild(wrap_in_div(create_tag_button(tags_table[row][col], 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('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(updated_tags, 500);
    });
  }

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

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

    edit_form.addEventListener('submit', (e) => {
      if (!tags_submit_listener())
        e.preventDefault();
    });
  }

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

    actions_ul = li.parentElement;

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

  function add_addon_actions() {
    if (actions_ul === null) 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( 0, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image',              'scale-image-fit');
    add_action(() => { scale_image( 1, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor');
    add_action(() => { scale_image( 2, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)',   'scale-image-ver');
    add_action(() => { scale_image(-1, true); scroll_to_image(config.scroll_to_image_center); }, 'Reset image size',       'reset-image');

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

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

    add_action(() => { 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.indexOf('duplicate') === -1) {
      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.indexOf('legitimate_variation') === -1) {
      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.indexOf('potential_duplicate') === -1) {
      pot_button.disabled = true;
    } else {
      pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; };
      pot_button.disabled = false;
    }
  }

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

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

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

  function add_tag(tag, skip_common_tags_update = false) {
    const tags = get_tags_array();

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

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

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

    tags_changed = true;
    updated_tags(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;
    updated_tags(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().indexOf(tag) !== -1);
  }

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

  // event that gets called whenever post_tags changes
  function updated_tags(skip_common_tags_update = 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_update);
    }
  }



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

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

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

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

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

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

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

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


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

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

      // workaround for Galinoa's Sankaku Channel Dark: don't read .width/.height attributes but read "Details"
      let res = null;
      const lowres = document.getElementById('lowres');
      if (lowres !== null) {
        res = lowres.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('addon error: image/video resolution not in "Details"?! Disabled scaling.');
        return null;
      }

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

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

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

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

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

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

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

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

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

      return;
    }

    let left_side;

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

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

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

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

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


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

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

    image_data.current_height = new_height;
  }

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

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

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

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

  // when resize notice is hidden (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(-1, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'f': // f(it)
          scale_image(0, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'g': // g
          scale_image(1, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 'h': // h
          scale_image(2, true);
          scroll_to_image(config.scroll_to_image_center);
          break;
        case 's': // s(imilar)
          if (post_id === null) {
            show_notice('addon error: couldn\'t find post id?!');
          } else {
            open_in_tab(location.origin + '/post/similar?id=' + post_id);
          }
          break;
        case 'd': // d(elete)
          if (post_id === null) {
            show_notice('addon error: couldn\'t find post id?!');
          } else if (!found_delete_action) {
            show_notice('addon error: Delete action not found, no permission?');
          } else {
            open_in_tab(location.origin + '/post/delete/' + post_id);
          }
          break;
      }
    }, true);
  }

  /***********************/
  /* 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('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_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('addon error: couldn\'t add "⚙" tag page link, check console');
      console_log(error);
    }
  }


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

  load_config();

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

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

  if (pathname === '/' || pathname === '/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 for dynamically loaded posts (from auto paging)
    add_speaker_icons_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_speaker_icons_observer((node) => (node.id === 'recommendations'));

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

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

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

  }




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

  async function init() {
    dom_content_loaded = true;

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

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

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

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

    //add speaker icons that the observer may have missed
    add_speaker_icons(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 (added_mode_options) {
        // add_mode_options() was called early, as it should
        PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
      } else {
        // if not, catch up on it later
        call_postmodemenu_change = true;
      }

      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 {
        post_id = pathname.substring(pathname.lastIndexOf('/') + 1);
      }

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

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

      image_data = read_image_data();
      if (config.scale_image) scale_image(config.scale_mode, false);
      if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center);
      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();

      add_tags_change_listener();
      add_tags_submit_listener();


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

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

      add_dtext_style_buttons();

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

      add_tag_edit_gear();

    }

  }

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