SankakuAddon

Adds a few quality of life improvements on SankakuChan: 'Set Parent', image scaling, duplicate tagging/flagging, muting videos. Fully configurable using localStorage.

As of 2016-09-29. See the latest version.

// ==UserScript==
// @name        SankakuAddon
// @namespace   SankakuAddon
// @description Adds a few quality of life improvements on SankakuChan: 'Set Parent', image scaling, duplicate tagging/flagging, muting videos. Fully configurable using localStorage.
// @include     http://chan.sankakucomplex.com/*
// @include     https://chan.sankakucomplex.com/*
// @include     http://idol.sankakucomplex.com/*
// @include     https://idol.sankakucomplex.com/*
// @run-at      document-start
// @version     0.91
// @grant       none
// ==/UserScript==

var version = "v0.91"

//TODO check if compatible with chrome
//TODO add option to disable flash video scaling
//TODO always load config from local storage or use some kind of eventlistener for config changes
//TODO do more sanity checks on duplicate tag (e.g. only allow saving when parent id is set)
//IDEA hotkeys for quick access of different modes

//known bugs/limitations:
//#1 new modes are sometimes not at the last place in the dropdown menu
//#2 image scaling doesn't scale translation boxes
//flash videos are not muted (unfixable)


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

var default_config = {
  scroll_to_image:true,
  scale:true,
  scale_mode:0, //hardcoded exception in save/load_config
  scale_on_resize:false,
  video_mute:true,
  video_controls:true,
  setparent_deletepotentialduplicate:false,
  hide_headerlogo:false
};

var config = JSON.parse(JSON.stringify(default_config));

var use_local_storage = (typeof(Storage) !== "undefined");

function save_config() {
  for (var key in config) {
    var cfg_key = "config." + key;

    var cfg_elem = document.getElementById(cfg_key);
    if (cfg_elem === null) {
      notice("addon error: couldn't find config element " + cfg_key + "!");
      return;
    }

    if (key === "scale_mode") {
      config[key] = cfg_elem.value;
    } else {
      config[key] = cfg_elem.checked;
    }
  }

  if (use_local_storage) {
    localStorage.setItem("addon_config", JSON.stringify(config));
  } else {
    notice("addon: couldn't save settings to local storage. check permissions.");
  }
}

function load_config() {
  if (use_local_storage) {
    var value = localStorage.getItem("addon_config");
    if (value) config = JSON.parse(value);
  }

  for (var key in config) {
    var cfg_key = "config." + key;

    var cfg_elem = document.getElementById(cfg_key);
    if (cfg_elem === null) {
      notice("addon error: couldn't find config element " + cfg_key + "!");
      return;
    }

    cfg_elem.addEventListener("input", function(e) { save_config(); hide_headerlogo(config.hide_headerlogo); });

    if (key === "scale_mode") {
      cfg_elem.value = config[key];
    } else {
      cfg_elem.checked = config[key];
    }
  }
}

function reset_config() {
  config = JSON.parse(JSON.stringify(default_config));

  if (use_local_storage) {
    localStorage.clear(); //WARNING: might break other scripts
  }

  load_config();
}

function show_config_overlay(bool) {
  document.getElementById("cfg_overlay").style.display = (bool) ? "" : "none";
}


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

function getScrollbarWidth() { //from Stack Overflow
  var outer = document.createElement("div");
  outer.style.visibility = "hidden";
  outer.style.width = "100px";
  outer.style.msOverflowStyle = "scrollbar"; // needed for WinJS apps

  document.body.appendChild(outer);

  var widthNoScroll = outer.offsetWidth;
  // force scrollbars
  outer.style.overflow = "scroll";

  // add innerdiv
  var inner = document.createElement("div");
  inner.style.width = "100%";
  outer.appendChild(inner);

  var widthWithScroll = inner.offsetWidth;

  // remove divs
  outer.parentNode.removeChild(outer);

  return widthNoScroll - widthWithScroll;
}

//helper function to modify nodes on creation
function register_observer(nodeID, nodeModifier) {
  var observer = new MutationObserver(function(mutations) {
    for (var i=0; i<mutations.length; i++) {
      var mutationAddedNodes = mutations[i].addedNodes;
      for (var j=0; j<mutationAddedNodes.length; j++) {
        var node = mutationAddedNodes[j];
        if (node.id && node.id == nodeID) {
          nodeModifier(node);
          observer.disconnect();
          return;
        }
      }
    }
  });
  observer.observe(document, {childList: true, subtree: true});
}


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

function hide_headerlogo(bool) {
  document.getElementById("headerlogo").style.display = (bool ? "none" : "");
}


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

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


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

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

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

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

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

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

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

  update_tag_buttons();
}

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

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

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

  update_tag_buttons();
}

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

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

  if (!taglist || !dup_button || !var_button || !pot_button) {
    notice("addon error: couldn't find tag buttons.");
    return;
  }

  var tags = get_tags_array();

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

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

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


//flag option with default text
function flag_duplicate(id, reason_suffix) {
  var parent_id = document.getElementById("post_parent_id");
  if (parent_id) {
    parent_id = parent_id.value;
  }

  //TODO check if parent_id was actually saved, not just typed
  if (!parent_id || parent_id.length === 0) {
    notice("addon: no parent id set!");
    return false;
  }

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

  if (old_tags.indexOf("duplicate") == -1) {
    notice("addon: not tagged as duplicate!");
    return false;
  }

  var reason = prompt("Why should this post be reconsidered for moderation?", "duplicate of " + parent_id + reason_suffix);
  if (reason === null) {
    return false;
  }
  new Ajax.Request("/post/flag.json", {
    parameters: {
      "id": id,
      "reason": reason
    },
    onComplete: function(response) {
      var resp = response.responseJSON;
      if (resp.success) {
        notice("Post was resent to moderation queue");
      } else {
        notice("Error: " + resp.reason);
      }
    }
  });
}


var img_aspect_ratio = null; //save image aspect ratio instead of recalculating

//stretch image/video/flash
function scale_image(mode) {
  mode = Number(mode)
  if (mode === NaN) {
    notice("addon error: scaling mode wasn't a number?");
    return;
  }

  var img = document.getElementById("image"); //image or video
  if (!img) {
    //flash video
    img = document.getElementById("non-image-content");
    if (!img) return;
    img = img.getElementsByTagName("embed")[0];
    if (!img) return;
  }

  if (img_aspect_ratio === null)
    img_aspect_ratio = img.width / img.height;

  //reset scaling
  if (mode === -1) {
    img.style.width = null;
    img.style.height = null;
    return;
  }

  //target rect
  var img_rect_w = Math.max(window.innerWidth - img.getBoundingClientRect().left - getScrollbarWidth() - 1, 1);
  var img_rect_h = Math.max(window.innerHeight - 1, 1);
  var img_rect_aspect_ratio = img_rect_w / img_rect_h;

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

  //horizontal
  if (mode === 1) {
    img.style.width  = Math.floor(img_rect_w) + "px";
    img.style.height = Math.floor(img_rect_w / img_aspect_ratio) + "px";

  //vertical
  } else if (mode === 2) {
    img.style.width  = Math.floor(img_rect_h * img_aspect_ratio) + "px";
    img.style.height = Math.floor(img_rect_h) + "px";
  }
}

function scroll_to_image() {
  var img = document.getElementById("image");
  if (!img) img = document.getElementById("non-image-content");
  if (!img) return;
  img.scrollIntoView();
}


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


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

if (location.pathname === "/" || location.pathname.lastIndexOf("/post/similar", 0) === 0) {

  // add new modes right when dropdown is added to prevent it being reset to "view post" on page reloads
  register_observer("mode", function(node) { add_mode_options(node); });


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

} else if (location.pathname.lastIndexOf("/post/show/", 0) === 0) {

  /* mutes all videos in document
  var mute_videos = function() {
    var videos = document.querySelectorAll("video");
    for (var i = 0; i < videos.length; i++) {
      if (config.video_mute)     videos[i].muted = true;
      if (config.video_controls) videos[i].controls = true;
    }
  };
  */

  var mute_video = function(node) {
    if (node.nodeType !== Node.ELEMENT_NODE) return;
    if (node.tagName !== "VIDEO") return;

    if (config.video_mute)     node.muted = true;
    if (config.video_controls) node.controls = true;
  };

  register_observer("image", function(node) {
      mute_video(node);
  });

}




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

document.addEventListener("DOMContentLoaded", function() {

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


  //add config overlay
  var cfg_overlay = document.createElement("div");
  cfg_overlay.style = "display:none; border:1px solid #DDD; top: 170px; width: 400px; height: 225px; position:fixed; left: 0; right: 0; margin: 0 auto; overflow: auto; background-color: white; z-index: 10001;";
  cfg_overlay.id = "cfg_overlay";
  cfg_overlay.innerHTML = ""
+ "<div style='display: table; position: absolute; height: 100%; width: 100%'>"
+ "<div style='display: table-cell; vertical-align: middle'>"
+ "<div style='padding: 8px; margin-left: auto; margin-right: auto;'>"
+   "<b>Sankaku Addon "+version+"</b><br/>"
+   "<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid #DDD;'>"
+   "<span>"
+     "<input id='config.scroll_to_image' type='checkbox'>"
+     "<span>Scroll to image when opening post</span><br>"
+   "</span>"
+   "<span>"
+     "<input id='config.scale' type='checkbox'>"
+     "<span>Scale image when opening post</span><br>"
+   "</span>"
+   "<span>"
+     "<input id='config.scale_on_resize' type='checkbox'>"
+     "<span title=\"This uses the 'scale image mode' setting, so it doesn't work well when using the manual scaling actions.\" style='cursor:help'>Scale image on window resize</span><br>"
+   "</span>"
+   "<span>"
+     "<span>Scale image mode: </span>"
+     "<select id='config.scale_mode'>"
+       "<option value=0>Fit to window</option>"
+       "<option value=1>Fit horizontally</option>"
+       "<option value=2>Fit vertically</option>"
+     "</select>"
+     "<br>"
+   "</span>"
+   "<span>"
+     "<input id='config.video_mute' type='checkbox'>"
+     "<span>Mute videos*</span><br>"
+   "</span>"
+   "<span>"
+     "<input id='config.video_controls' type='checkbox'>"
+     "<span>Show video controls*</span><br>"
+   "</span>"
+   "<span>"
+     "<input id='config.setparent_deletepotentialduplicate' type='checkbox'>"
+     "<span>Delete potential_duplicate tag when using \"Set Parent\"</span><br>"
+   "</span>"
+   "<span>"
+     "<input id='config.hide_headerlogo' type='checkbox'>"
+     "<span>Hide header logo</span><br>"
+   "</span>"
+   "<button id='config_close'>Close</button>"
+   "<button id='config_reset'>Reset settings</button>"
+   "<span>&nbsp;*requires a page reload.</span>"
+ "</div>"
+ "";

  document.body.appendChild(cfg_overlay);
  document.getElementById("config_close").onclick = function() { show_config_overlay(false); return false; };
  document.getElementById("config_reset").onclick = function() {
    reset_config();
    hide_headerlogo(config.hide_headerlogo); //TODO automatically update
    return false;
  };


  //add config button to navbar
  var navbar = document.getElementById("navbar");
  if (navbar === null) {
    notice("addon error: couldn't find \"navbar\" element! Config dialog disabled.");
  } else {
    navbar.style.whiteSpace = "nowrap"; //hack to fit config button

    var a = document.createElement("A");
    a.href = "#";
    a.onclick = function() { load_config(); show_config_overlay(true); return false; };
    a.innerHTML = "Addon config";
    a.style.fontSize = "110%";

    var newli = document.createElement("li");
    newli.className = "lang-select"; //
    newli.appendChild(a);
    navbar.appendChild(newli);
  }


  load_config();
  hide_headerlogo(config.hide_headerlogo); //TODO automatically update


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

  if (location.pathname === "/" || location.pathname.lastIndexOf("/post/similar", 0) === 0) {

    if (!PostModeMenu.old_change) PostModeMenu.old_change = PostModeMenu.change;
    if (!PostModeMenu.old_click)  PostModeMenu.old_click  = PostModeMenu.click;

    //add change events
    PostModeMenu.change = function() {
      var s = $F("mode");

      PostModeMenu.old_change();

      if (s == "apply-tag-script") {
        document.body.setStyle({
          backgroundColor: "#FDF" //weaken color intensity
        });
      } else if (s == "choose-parent") {
        document.body.setStyle({
          backgroundColor: "#FFD"
        });
      } else if (s == "set-parent") {
        if (Cookie.get("chosen-parent") === null) {
          notice("addon: Choose parent first!");
          $("mode").value = "choose-parent";
          PostModeMenu.change();
        } else {
          document.body.setStyle({
            backgroundColor: "#DFF"
          });
        }
      }
    };

    //add click events
    PostModeMenu.click = function(post_id) {

      if (PostModeMenu.old_click(post_id)) {
        return true;
      }

      var s = $("mode");

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

    //guarantee that "mode" variable correctly changes to new modes when loading page
    PostModeMenu.change();


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

  } else if (location.pathname.lastIndexOf("/post/show/", 0) === 0) {

    if (config.scale) scale_image(config.scale_mode);
    if (config.scroll_to_image) scroll_to_image();

    window.addEventListener("resize", function(e) {
      if (config.scale && config.scale_on_resize) scale_image(config.scale_mode);
    });

    var post_id = document.getElementById("hidden_post_id").innerHTML;
    if (post_id % 1 !== 0) {
      notice("addon error: hidden_post_id not found! Addon actions disabled.");
      return;
    }

    var li = document.getElementById("add-to-pool");
    if (li === null) {
      notice("addon error: couldn't find \"add-to-pool\" element! Addon actions disabled.");
      return;
    }


    //add actions
    if (document.getElementById("flag-duplicate") === null) {
      var actions_ul = li.parentElement;

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

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

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

      add_action(function() { scale_image(0); scroll_to_image(); },  "Fit image",              "scale-image-fit");
      add_action(function() { scale_image(1); scroll_to_image(); },  "Fit image (Horizontal)", "scale-image-hor");
      add_action(function() { scale_image(2); scroll_to_image(); },  "Fit image (Vertical)",   "scale-image-ver");
      add_action(function() { scale_image(-1); scroll_to_image(); }, "Reset image size",       "reset-image");
      add_action(function() { flag_duplicate(post_id);                           }, "Flag duplicate",              "flag-duplicate");
      add_action(function() { flag_duplicate(post_id, ", visually identical");   }, "Flag duplicate (identical)",  "flag-duplicate-identical");
      add_action(function() { flag_duplicate(post_id, " with worse quality");    }, "Flag duplicate (quality)",    "flag-duplicate-quality");
      add_action(function() { flag_duplicate(post_id, " with worse resolution"); }, "Flag duplicate (resolution)", "flag-duplicate-resolution");
    }


    //add tag buttons
    var edit_form = document.getElementById("edit-form");
    if (edit_form === null) {
      notice("addon error: couldn't find \"edit_form\" element! Tag buttons disabled.");
      return;
    }
    var button_place = edit_form.children[1].children[0].children[0].children[0];

    //TODO add 'clear parent id' button

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

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

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

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

    update_tag_buttons();
  }

}, false);