// ==UserScript==
// @name SankakuAddon
// @namespace SankakuAddon
// @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, scrolling to image, thumbnail icons for loud/animated posts, muting/pausing videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, easier duplicate tagging/flagging. Fully configurable through the Addon config.
// @author sanchan
// @version 0.99.25
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZHPKwRhGMc/dolYbeIgOewBOeyWKDlqFZflsFZZXGZmZ3fVzphmZpNclYuDchAXvw7+A67KlVKKlOTgL/DrIo3n3VUr8U7vPJ++7/t9et/vC6FUybC8+gGwbN9NTyRjs9m5WOMTETpoox00w3Mmp8cz/Dveb6hT9Tqhev2/78/RkjM9A+qahIcNx/WFR4VTy76jeEO4wyhqOeF94bgrBxS+ULpe5UfFhSq/KnYz6TEIqZ6xwg/Wf7BRdC3hfuEeq1Q2vs+jbhIx7ZlpqV0yu/FIM0GSGDplFinhk5BqS2Z/+wYqvimWxGPI32EFVxwFiuKNi1qWrqbUvOimfCVWVO6/8/TyQ4PV7pEkNDwEwUsvNG7B52YQfBwEwechhO/hzK75lySnkTfRN2tazx5E1+DkvKbp23C6Dp13juZqFSksM5TPw/MxtGah/Qqa56tZfa9zdAuZVXmiS9jZhT7ZH134ArhcZ+m/WStSAAAACXBIWXMAAAsSAAALEgHS3X78AAAAeElEQVQ4y2NgoCX4Xyb7H4TxqWHCo7keG5toA4CgAQebsAHYbMTlCiYibMfrCiZibcIlx0SE3/GGBRM+Gxi7HjeCMD41TESGPE5XMOGzHRsbnxcIxbsDDAMts4cbjmR7A4kpvQHkMiZCKY1QSmXBZTKedNBA1RwLAFCeNCTVhz2FAAAAAElFTkSuQmCC
// @match https://chan.sankakucomplex.com/*
// @match https://idol.sankakucomplex.com/*
// @run-at document-start
// @noframes
// @grant GM.registerMenuCommand
// @grant GM_registerMenuCommand
// @grant GM.addStyle
// @grant GM.openInTab
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.addValueChangeListener
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// ==/UserScript==
(async function(unsafeWindow) {
'use strict';
const VERSION = 'v0.99.25';
// 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 at least partially support 'GM.' functions)
if (typeof GM !== 'undefined' && typeof GM.info === 'object') {
IS_MONKEY = true;
IS_GREASEMONKEY4 = (GM.info.scriptHandler === 'Greasemonkey');
// polyfills for ViolentMonkey
if (typeof GM.addValueChangeListener === 'undefined') GM.addValueChangeListener = GM_addValueChangeListener;
if (typeof GM.registerMenuCommand === 'undefined') GM.registerMenuCommand = GM_registerMenuCommand; // doesn't support accessKey
}
function open_in_tab(url) {
if (IS_MONKEY) GM.openInTab(url, false);
else window.open(url); // requires popup permission
}
function add_style(css) {
if (IS_MONKEY) {
GM.addStyle(css);
} else {
const sheet = document.createElement('STYLE');
sheet.innerText = css;
document.head.appendChild(sheet);
}
}
// the site uses a ton of ancient, non-standard polyfills/prototype overrides, e.g.
// Array.from(new Set([1])) returns [] instead of [1]
// JSON.parse(JSON.stringify([1])) returns "[1]" instead of [1]
// Array.from(s) can be replaced by [...s]
// to use proper JSON we need to temporarily unbind the site's toJSON functions
const toJSON_OBJECTS = [Object, Array.prototype, Number.prototype, String.prototype];
function delete_toJSONs() {
const toJSON_originals = [];
for (const obj of toJSON_OBJECTS) {
if (obj.hasOwnProperty('toJSON')) {
toJSON_originals.push({ obj, func: obj.toJSON });
delete obj.toJSON;
}
}
return toJSON_originals;
}
function restore_toJSONs(toJSON_originals) {
for (const { obj, func } of toJSON_originals)
obj.toJSON = func;
}
function JSON_stringify(obj, replacer, space) {
let toJSON_originals;
try {
toJSON_originals = delete_toJSONs();
return JSON.stringify(obj, replacer, space);
} finally {
restore_toJSONs(toJSON_originals);
}
}
// enables JSON.stringify to stringify Sets
function set_replacer(key, value) {
if (typeof value === 'object' && value instanceof Set)
return { t: 'Set', v: [...value] };
return value;
}
// enables JSON.parse to parse Sets
function set_reviver(key, value) {
if (typeof value === 'object' && value.t === 'Set')
return new Set(value.v);
return value;
}
/***************************/
/* configuration functions */
/***************************/
const IS_IDOL = (window.location.hostname === 'idol.sankakucomplex.com' ? 1 : 0);
const HISTORY_KEY = (IS_IDOL ? 'view_history_idol' : 'view_history');
const COMMON_TAGS_KEYS = ['common_tags_json', 'common_tags_json_idol'];
const COMMON_TAGS_KEY = COMMON_TAGS_KEYS[IS_IDOL];
const OTHER_COMMON_TAGS_KEY = COMMON_TAGS_KEYS[1 - IS_IDOL];
const DEFAULT_CONFIG = {
scroll_to_image: true,
scale_image: true, // and video
scale_only_downscale: false,
scale_flash: false,
scale_mode: 0,
scale_on_resize: false,
scroll_to_image_center: true,
video_pause: false,
video_mute: true,
set_video_volume: false,
video_volume: 50,
video_controls: true,
show_speaker_icon: true,
show_animated_icon: true,
setparent_deletepotentialduplicate: false,
editform_deleteuselesstags: false,
hide_headerlogo: false,
tag_search_buttons: true,
tag_menu: true,
tag_menu_scale: '30%',
tag_menu_layout: 1,
common_tags_json: DEFAULT_TAGLIST,
common_tags_json_idol: '[ {"name":"test tags", "tags":["tag1 tag2", ["grouped_tag1 grouped_tag2"], "tag3 tag4"] }, { "tags":[ "next_line tag5 tag6", ["grouped_tag3 grouped_tag4"] , "tag7 tag8"] }, {"name":"another\u00A0category", "tags":["t1 t2 t3"]} ]',
sankaku_channel_dark_compatibility: false,
view_history_enabled: false,
view_history: new Set(),
view_history_idol: new Set(),
wiki_template: '',
};
const USE_MONKEY_STORAGE = IS_MONKEY;
let USE_LOCAL_STORAGE;
try {
USE_LOCAL_STORAGE = !!localStorage.getItem;
} catch (error) { // DOMException
USE_LOCAL_STORAGE = false;
}
const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids
const config = Object_clone(DEFAULT_CONFIG); // load default
// applied to loaded/set config entries (e.g. config elements return strings when we need numbers)
const CONFIG_FIXER = {
scale_mode: Number,
tag_menu_layout: Number,
};
function fix_config_entry(key, value) {
const fixer = CONFIG_FIXER[key];
return (fixer !== undefined ? fixer(value) : value);
}
// permanently save setting to localStorage (and broadcast to other tabs)
function save_setting(key, value) {
value = fix_config_entry(key, value);
if (USE_MONKEY_STORAGE) {
GM.setValue(key, JSON_stringify(value, set_replacer)).catch((reason) => {
show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason);
});
return;
}
if (!USE_LOCAL_STORAGE) {
show_notice(console.warn, `addon: couldn't save setting "${KEY_PREFIX + key}" to localStorage. check permissions`);
return;
}
try {
localStorage.setItem(KEY_PREFIX + key, JSON_stringify(value, set_replacer));
} catch (error) {
show_notice(console.error, `addon error: couldn't save setting "${KEY_PREFIX + key}" to localStorage, check console`, error);
}
}
async function load_config() {
const monkey_values = {};
if (USE_MONKEY_STORAGE) {
const promises = [];
for (const key of Object.keys(config)) {
promises.push(GM.getValue(key).then((value) => {
monkey_values[key] = value;
}));
}
await Promise.all(promises);
}
for (const key of Object.keys(config)) {
let value = config[key]; // default already loaded
let stored_value = monkey_values[key];
if (stored_value === undefined && USE_LOCAL_STORAGE)
stored_value = localStorage.getItem(KEY_PREFIX + key);
if (stored_value !== undefined && stored_value !== null) {
try {
value = JSON.parse(stored_value, set_reviver);
} catch (error) {
show_notice(console.error, `addon error: couldn't load setting "${key}"`, error);
}
}
update_setting(key, value); // fire regardless
}
}
function storage_changed(key, old_value, new_value, remote) {
try {
if (!remote) return; // only listen to other tabs
if (new_value === undefined || new_value === null) {
// entry was removed, reset setting to default
update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
} else {
// entry was added or changed
const value = JSON.parse(new_value, set_reviver);
// workaround for post view history race condition
if (key === HISTORY_KEY) {
const new_ids = Set_difference(value, config[key]);
if (new_ids.size === 0) return;
// integrate newly received post ids into view history
config[key] = Set_union(value, config[key]);
// save new view history and broadcast it to other tabs,
// which in turn might broadcast their ids back to us
save_setting(key, value);
// live update thumbnails
if (!is_personal_post_page()) {
for (const id of new_ids) {
const thumbs = thumbnail_cache.get(id);
if (thumbs === undefined) continue;
for (const thumb of thumbs)
fadeout_post(thumb);
}
}
return; // don't call update_setting()
}
update_setting(key, value);
}
} catch (error) {
show_notice(console.error, 'storage_changed() failed, check console', error);
}
}
// localStorage from other tabs changed
function local_storage_changed(e) {
if (e.storageArea !== localStorage) return;
if (e.key === null) return; // ignore external localStorage.clear() for now
// only look at SankakuAddon specific changes
if (!e.key.startsWith(KEY_PREFIX)) return;
const key = e.key.substring(KEY_PREFIX.length);
storage_changed(key, e.oldValue, e.newValue, true);
}
function update_setting(key, value) {
config[key] = fix_config_entry(key, value);
if (key === 'scale_on_resize') {
if (value) add_scale_on_resize_listener();
else remove_scale_on_resize_listener();
}
update_config_dialog_by_key(key);
if (key === 'hide_headerlogo') update_headerlogo();
}
function reset_setting(key) {
if (USE_MONKEY_STORAGE) GM.deleteValue(key);
if (USE_LOCAL_STORAGE) localStorage.removeItem(KEY_PREFIX + key); // also delete if USE_MONKEY_STORAGE
update_setting(key, Object_clone(DEFAULT_CONFIG[key]));
}
function reset_config() {
for (const key of Object.keys(config)) {
// don't clear the history so the clear history button makes more sense
if (key === 'view_history' || key === 'view_history_idol') continue;
// don't reset the common tags list of the other site
if (key === OTHER_COMMON_TAGS_KEY) continue;
reset_setting(key);
}
}
// template for the config dialog
const CONFIG_TEMPLATE = {
scroll_to_image: {type: 'checkbox', desc: 'Scroll to image/video when opening post'},
scroll_to_image_center: {type: 'checkbox', desc: 'Scroll to center of image/video, else scroll to top'},
scale_image: {type: 'checkbox', desc: 'Scale image/video when opening post'},
scale_only_downscale: {type: 'checkbox', desc: 'Only downscale'},
scale_flash: {type: 'checkbox', desc: 'Also scale flash videos'},
scale_on_resize: {type: 'checkbox', desc: 'Scale image on window resize', title: 'This uses the \'scale image mode\' setting, so it doesn\'t work well when using the manual scaling actions.'},
scale_mode: {type: 'select', desc: 'Scale image/video mode: ', options: {0: 'Fit to window', 1: 'Fit horizontally', 2: 'Fit vertically'}},
video_pause: {type: 'checkbox', desc: 'Pause (non-flash) videos*'},
video_mute: {type: 'checkbox', desc: 'Mute (non-flash) videos*'},
set_video_volume: {type: 'checkbox', desc: 'Set (non-flash) video volume to: '},
video_controls: {type: 'checkbox', desc: 'Show video controls*'},
tag_search_buttons: {type: 'checkbox', desc: 'Show + - tag search buttons*'},
show_speaker_icon: {type: 'checkbox', desc: 'Show 🔊 icon on thumbnail if it has audio*'},
show_animated_icon: {type: 'checkbox', desc: 'Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*'},
view_history_enabled: {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'},
editform_deleteuselesstags: {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag (if there have been changes)'},
hide_headerlogo: {type: 'checkbox', desc: 'Hide header logo'},
sankaku_channel_dark_compatibility: {type: 'checkbox', desc: 'Galinoa\'s Sankaku Channel Dark compatibilty*'},
tag_menu: {type: 'checkbox', desc: 'Activate tag menu*'},
[COMMON_TAGS_KEY]: {type: 'text', desc: 'Common tags list (JSON format):'},
tag_menu_layout: {type: 'select', desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}},
wiki_template: {type: 'text', desc: 'Wiki template:', title: 'Text that will be be shown in a separate textarea on wiki add/edit pages so it can easily be copied'},
};
// whether a config element's value are accessed via '.value' (or otherwise '.checked')
function is_value_element(key) {
if (key === 'video_volume') return true; // "video_volume" is hardcoded in add_config_dialog()
if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type
const type = CONFIG_TEMPLATE[key].type;
return (type === 'select' || type === 'text');
}
// calls f(cfg_elem, key, get_value) for each existing config element
function foreach_config_element(f) {
for (const key of Object.keys(config)) {
const cfg_elem = document.getElementById(KEY_PREFIX + key);
if (cfg_elem === null) continue;
if (is_value_element(key)) f(cfg_elem, key, () => cfg_elem.value);
else f(cfg_elem, key, () => cfg_elem.checked);
}
}
function update_config_dialog_by_key(key) {
const cfg_elem = document.getElementById(KEY_PREFIX + key);
if (cfg_elem !== null) {
if (is_value_element(key)) cfg_elem.value = config[key];
else cfg_elem.checked = config[key];
}
}
function update_config_dialog() {
for (const key of Object.keys(config)) update_config_dialog_by_key(key);
}
function update_headerlogo() {
hide_headerlogo(config.hide_headerlogo);
}
function show_config_dialog(bool) {
document.getElementById('cfg_dialog').style.display = (bool ? 'block' : 'none');
}
/********************/
/* helper functions */
/********************/
function set_cookie(name, value, valid_for_days = 365) {
const date = new Date();
date.setTime(date.getTime() + (valid_for_days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/`;
}
function get_cookie(name) {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const kv = cookie.split('=');
if (kv.length === 2 && kv[0].trim() === name) {
return decodeURIComponent(kv[1]);
}
}
return '';
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// helper function to modify nodes on creation
function register_observer(node_predicate, node_modifier) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node_predicate(node)) {
if (node_modifier(node, observer)) { // are we done?
observer.disconnect();
return;
}
}
}
}
});
observer.observe(document, { childList: true, subtree: true });
return observer;
}
function get_scrollbar_width() { // from Stack Overflow
const outer = document.createElement('DIV');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
document.body.appendChild(outer);
const width_no_scroll = outer.offsetWidth;
outer.style.overflow = 'scroll'; // force scrollbars
const inner = document.createElement('DIV');
inner.style.width = '100%' + width_no_scroll;
outer.appendChild(inner);
const width_with_scroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
return width_no_scroll - width_with_scroll;
}
// (almost) deepclone objects
function Object_clone(obj) {
if (typeof obj === 'object') {
// shallow clone Arrays and Sets
if (obj instanceof Array) return [...obj];
if (obj instanceof Set) return new Set(obj);
const new_obj = {};
for (const [key, value] of Object.entries(obj))
new_obj[key] = Object_clone(value);
return new_obj;
}
return obj;
}
function Set_difference(a, b) {
return new Set([...a].filter((x) => !b.has(x)));
}
function Set_union(a, b) {
return new Set([...a, ...b]);
}
function show_notice(logFunc, ...msg) {
if (unsafeWindow.notice) unsafeWindow.notice(msg[0]);
if (logFunc) logFunc(...msg);
}
function get_original_background_color() {
// the background-color style gets changed through the (site)script, but we need the original one
// there has to be a better way than this, right?
const current = window.getComputedStyle(document.body).getPropertyValue('background-color');
document.body.style.backgroundColor = '';
const original = window.getComputedStyle(document.body).getPropertyValue('background-color');
document.body.style.backgroundColor = current;
return original;
}
// "rgb(r,g,b)" -> [int(r), int(g), int(b)]
function rgb_to_array(rgb) {
const arr = rgb.substring(rgb.indexOf('(') + 1, rgb.lastIndexOf(')')).split(/,\s*/);
for (let i = 0; i < arr.length; i++)
arr[i] = parseInt(arr[i], 10);
return arr;
}
function rgb_array_is_dark(rgb_array) {
let avg = 0;
for (let i = 0; i < rgb_array.length; i++)
avg += rgb_array[i];
avg /= rgb_array.length;
return (avg <= 128);
}
function rgb_array_shift(rgb, shift) {
const shifted = [];
for (let i = 0; i < 3; i++)
shifted.push(Math.min(Math.max(rgb[i] + shift, 0), 255));
return shifted;
}
// [r, g, b] -> "rgb(r,g,b)"
function rgb_array_to_rgb(rgb) {
if (rgb.length === 3)
return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ')';
}
function is_darkmode() {
const theme = get_cookie('theme');
if (theme !== '' && Number(theme) !== 0)
return true;
// fallback
const rgb = rgb_to_array(get_original_background_color());
return rgb_array_is_dark(rgb);
}
// helper function to adjust background colors based on light or dark mode
function shifted_backgroundColor(shift) {
const rgb = rgb_to_array(get_original_background_color());
const shifted_rgb = rgb_array_shift(rgb, (is_darkmode() ? 1 : -1) * shift);
return rgb_array_to_rgb(shifted_rgb);
}
/**************************/
/* general site functions */
/**************************/
const thumbnail_cache = new Map(); // id -> array of thumbnail elements
let cached_search_tags = null;
function get_search_tags() {
if (cached_search_tags !== null) return cached_search_tags;
const tags = new URL(window.location.href).searchParams.get('tags');
cached_search_tags = (tags === null ? [] : tags.split(' '));
return cached_search_tags;
}
// is own uploads or favorites page
function is_personal_post_page() {
const username = get_username();
if (username === null) return false;
const tags = get_search_tags();
return tags.includes('fav:' + username) || tags.includes('user:' + username);
}
function hide_headerlogo(hide) {
const headerlogo = document.getElementById('headerlogo');
if (headerlogo !== null) headerlogo.style.display = (hide ? 'none' : '');
}
function add_config_dialog() {
const cfg_dialog = document.createElement('DIV');
cfg_dialog.id = 'cfg_dialog';
cfg_dialog.style.display = 'none';
cfg_dialog.style.padding = '12px';
cfg_dialog.style.border = '1px solid ' + shifted_backgroundColor(32);
cfg_dialog.style.backgroundColor = get_original_background_color();
// fixed, centered div
cfg_dialog.style.top = '50%';
cfg_dialog.style.left = '50%';
cfg_dialog.style.transform = 'translate(-50%, -50%)';
cfg_dialog.style.position = 'fixed';
cfg_dialog.style.zIndex = '10002';
// scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins)
cfg_dialog.style.maxWidth = '90vw';
cfg_dialog.style.maxHeight = '90vh';
cfg_dialog.style.overflow = 'auto';
// generate the content of the config menu
let innerDivHTML = `<div style='font-weight: bold;'>SankakuAddon ${VERSION}</div>`
+ `<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid ${shifted_backgroundColor(32)};'>`;
// parse the config_template
for (const [key, value] of Object.entries(CONFIG_TEMPLATE)) {
const type = value.type;
const generate_span = () => `<span style="vertical-align: middle; ${value.title ? 'cursor:help; text-decoration: underline dashed; ' : ''}" `
+ `${value.title ? `title="${value.title}"` : ''} >${value.desc}</span>`;
innerDivHTML += '<div style="vertical-align: middle;">';
switch (type) {
case 'checkbox':
innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle;'>`;
innerDivHTML += generate_span();
// hardcode 'video_volume' element:
innerDivHTML += (key === 'set_video_volume' ? `<input id="${KEY_PREFIX}video_volume" type="number" min="0" max="100" size="4">%` : '');
break;
case 'select':
innerDivHTML += generate_span();
innerDivHTML += `<select id="${KEY_PREFIX}${key}">`;
for (const [k, v] of Object.entries(value.options))
innerDivHTML += `<option value="${k}">${v}</option>`;
innerDivHTML += '</select>';
break;
case 'text':
innerDivHTML += generate_span();
innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" style='width: 100%; box-sizing: border-box'></textarea>`;
break;
default:
show_notice(console.error, 'addon error: CONFIG_TEMPLATE is defective!', type);
continue;
}
innerDivHTML += '</div>';
}
innerDivHTML += '<div>';
innerDivHTML += '<button id="config_close" style="cursor: pointer;">Close</button>';
innerDivHTML += '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>';
innerDivHTML += '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>';
innerDivHTML += '</div>';
innerDivHTML += '<div> *requires a page reload.</div>';
cfg_dialog.innerHTML = innerDivHTML;
document.body.appendChild(cfg_dialog);
// add events
document.getElementById('config_close').onclick = () => { show_config_dialog(false); return false; };
document.getElementById('config_reset').onclick = () => { reset_config(); return false; };
document.getElementById('history_clear').onclick = () => { reset_setting(HISTORY_KEY); return false; };
foreach_config_element((cfg_elem, key, get_value) => {
cfg_elem.addEventListener('change', () => {
update_setting(key, get_value());
save_setting(key, get_value());
});
});
}
function add_config_button() {
const navbar = document.getElementById('navbar');
if (navbar === null) return;
navbar.style.whiteSpace = 'nowrap'; // hack to fit config button
const lang_select = navbar.querySelector('.lang-select');
if (lang_select !== null)
lang_select.style.borderRight = 0; // prevent config button from jumping a pixel on mouseover
const a = document.createElement('A');
a.href = '#';
a.onclick = () => { show_config_dialog(true); return false; };
a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config';
a.style.fontSize = '120%';
const li = document.createElement('LI');
li.className = 'lang-select'; // match style of top bar
li.appendChild(a);
navbar.appendChild(li);
}
function add_tag_search_buttons() {
const tagsidebar = document.getElementById('tag-sidebar');
if (tagsidebar === null) return;
const items = tagsidebar.getElementsByTagName('LI');
for (const item of items) {
const taglink = item.getElementsByTagName('A')[0];
const tagname = taglink.innerText.replace(/ /g, '_'); // hopefully this is the only edgecase
// generates click listeners
const tag_search_button_func = function(tagname) {
return function() {
const search_field = document.getElementById('tags');
const search_tags = search_field.value.trim().split(/\s+/);
const tag_index = search_tags.indexOf(tagname);
// add tag if missing, remove if existing
if (tag_index === -1) {
search_field.value = search_field.value.trim() + ' ' + tagname;
} else {
search_tags.splice(tag_index, 1);
search_field.value = search_tags.join(' ');
}
search_field.focus({ preventScroll: true });
return false;
};
};
{
const a = document.createElement('A');
a.href = '#';
a.innerText = '+';
a.onclick = tag_search_button_func(tagname);
item.insertBefore(a, taglink);
item.insertBefore(document.createTextNode(' '), taglink);
}
{
const a = document.createElement('A');
a.href = '#';
a.innerText = '-';
a.onclick = tag_search_button_func('-' + tagname);
item.insertBefore(a, taglink);
item.insertBefore(document.createTextNode(' '), taglink);
}
}
}
function modify_thumbnails(root) {
if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return;
if (root === null) return;
for (const thumb of root.getElementsByClassName('thumb')) {
// read thumbnail/post id
const pid = thumb.id;
if (typeof pid !== 'string' || !pid.startsWith('p')) {
show_notice(console.error, 'addon error: thumbnail doesn\'t have an id?!', thumb);
return;
}
const id = Number(pid.substring(1));
if (Number.isNaN(id)) {
show_notice(console.error, 'addon error: thumbnail id isn\'t a number?!', id);
return;
}
// use and update thumbnail_cache
let thumbs = thumbnail_cache.get(id);
if (thumbs === undefined) thumbs = [];
const is_new = !thumbs.includes(thumb);
if (is_new) thumbs.push(thumb);
thumbnail_cache.set(id, thumbs);
if (is_new) {
add_speaker_icon(thumb, id);
if (!is_personal_post_page())
fadeout_viewed_post(thumb, id);
}
}
}
function add_speaker_icon(thumb_span) {
const img = thumb_span.querySelector('.preview');
if (img === null) return;
const a = thumb_span.getElementsByTagName('A');
if (a.length === 0) return;
const icon = document.createElement('SPAN');
const tags = img.title.trim().split(/\s+/);
if (config.show_speaker_icon && (tags.includes('has_audio'))) {
icon.innerText = '🔊';
} else if (config.show_animated_icon && (tags.includes('animated') || tags.includes('video'))) {
icon.innerText = '⏩';
} else {
return;
}
icon.className = 'speaker_icon';
icon.style.color = '#666';
icon.style.position = 'absolute';
icon.style.top = '2px'; // account for border
icon.style.right = '2px';
icon.style.fontSize = '200%';
icon.style.textShadow = '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white';
icon.style.transform = 'translateX(50%) translateY(-50%)';
a[0].style.display = 'inline-block'; // makes the element fit its content
a[0].style.position = 'relative';
a[0].appendChild(icon);
}
// TODO this isn't optimal when the thumbnail has low contrast to the background
function fadeout_post(thumb_span) {
thumb_span.style.opacity = '20%';
}
function fadeout_viewed_post(thumb_span, id) {
if (config[HISTORY_KEY].has(id))
fadeout_post(thumb_span);
}
function add_thumbnail_observer(predicate) {
// don't hog CPU when disabled, but requires page reload to activate
if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return;
// this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded
register_observer(predicate, (node, observer) => {
modify_thumbnails(node);
return false; // listen forever
});
}
function configure_video(node) {
if (node === null || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== 'VIDEO') return;
if (config.video_pause) node.pause();
if (config.set_video_volume) node.volume = config.video_volume / 100.0;
if (config.video_mute) node.muted = true;
node.controls = config.video_controls;
}
/***********************************************/
/* main page / visually similar page functions */
/***********************************************/
let called_add_mode_options = false;
let call_postmodemenu_init_workaround = false;
let cached_username = null;
// won't work on any page
function get_username() {
if (cached_username !== null) return cached_username;
// read from the 'My Favorites' button (in one of the subnavs) in the navbar
const navbar = document.getElementById('navbar');
if (navbar === null) return null;
const fav_prefix = window.location.origin + '/?tags=fav%3A';
for (const a of navbar.getElementsByTagName('A')) {
if (typeof a.href === 'string' && a.href.startsWith(fav_prefix)) {
cached_username = a.href.substring(fav_prefix.length);
break;
}
}
return cached_username;
}
// this won't be called when not logged in or when the user does not have tag script permission
function add_mode_options() {
if (called_add_mode_options) return;
called_add_mode_options = true;
const mode_dropdown = document.getElementById('mode');
if (mode_dropdown === null) {
show_notice(console.log, 'addon error: add_mode_options() couldn\'t find mode dropdown?!');
return;
}
// override change event
mode_dropdown.removeAttribute('onchange');
mode_dropdown.onchange = PostModeMenu_change_override;
if (!IS_GREASEMONKEY4) {
if (mode_dropdown.options.namedItem('choose-parent') === null) {
const option = document.createElement('option');
option.text = 'Choose Parent';
option.value = 'choose-parent';
mode_dropdown.add(option);
}
if (mode_dropdown.options.namedItem('set-parent') === null) {
const option = document.createElement('option');
option.text = 'Set Parent';
option.value = 'set-parent';
mode_dropdown.add(option);
}
}
// add_mode_options() was called late
if (call_postmodemenu_init_workaround) {
PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
}
}
function add_postmode_hotkeys() {
document.addEventListener('keydown', (e) => {
const mode_dropdown = document.getElementById('mode');
if (mode_dropdown === null) return;
if (e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.target === mode_dropdown) {
e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
} else {
const tag = e.target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
return;
}
}
const old_mode = mode_dropdown.value;
switch (e.key) {
case 'v':
if (IS_GREASEMONKEY4) {
show_notice(console.warn, 'addon: \'Set Parent\' not yet supported in Greasemonkey');
return;
}
mode_dropdown.value = 'set-parent';
break;
case 'c':
if (IS_GREASEMONKEY4) {
show_notice(console.warn, 'addon: \'Choose Parent\' not yet supported in Greasemonkey');
return;
}
mode_dropdown.value = 'choose-parent';
break;
case 'q':
mode_dropdown.value = 'rating-q';
break;
case 's':
mode_dropdown.value = 'rating-s';
break;
case 'e':
mode_dropdown.value = 'rating-e';
break;
default:
return;
}
// couldn't set mode (option doesn't exist)
if (!mode_dropdown.value) mode_dropdown.value = old_mode;
PostModeMenu_change_override();
}, true);
}
function PostModeMenu_init_workaround() {
const mode_dropdown = document.getElementById('mode');
if (mode_dropdown === null) {
show_notice(console.error, 'addon error: PostModeMenu_init_workaround() couldn\'t find mode dropdown?!');
return;
}
// issue: new post modes can reset on page load if they were added too late
// reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then
// calls PostModeMenu.change, which sets the cookie to mode_dropdown.value.
// so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset
// solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added
const mode = get_cookie('addon_mode');
if (mode !== '') {
set_cookie('mode', mode, 7);
mode_dropdown.value = mode;
// setting mode failed, possible when changing to account with lower permissions
if (!mode_dropdown.value) {
show_notice(console.error, `addon error: couldn't set mode to ${mode}, resetting to 'view'`);
mode_dropdown.value = 'view';
set_cookie('mode', 'view', 7);
}
}
PostModeMenu_change_override();
}
async function PostModeMenu_change_override() {
const mode_dropdown = document.getElementById('mode');
if (mode_dropdown === null) {
show_notice(console.error, 'addon error: PostModeMenu_change_override() couldn\'t find mode dropdown?!');
return;
}
if (!mode_dropdown.value) {
show_notice(console.error, 'addon error: invalid mode, resetting to \'view\'');
mode_dropdown.value = 'view';
set_cookie('mode', 'view', 7);
}
const s = mode_dropdown.value;
// try to guarantee sitescript has loaded
while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined)
await sleep(100);
unsafeWindow.PostModeMenu.change();
set_cookie('addon_mode', s, 7); // set 'backup' cookie
const darkmode = is_darkmode();
if (s === 'add-fav') {
// FFFFAA, original. darkmode: luminance 40
document.body.style.backgroundColor = (darkmode ? '#505000' : '#FFA');
} else if (s === 'remove-fav') {
// FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40
document.body.style.backgroundColor = (darkmode ? '#504000' : '#FEA');
} else if (s === 'apply-tag-script') {
// AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40
document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF');
} else if (s === 'approve') {
// 2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: TODO
document.body.style.backgroundColor = '#FDF';
} else if (s === 'choose-parent') {
document.body.style.backgroundColor = (darkmode ? '#464600' : '#FFD');
} else if (s === 'set-parent') {
if (get_cookie('chosen-parent') === '') {
show_notice(console.warn, 'addon: Choose parent first!');
mode_dropdown.value = 'choose-parent';
PostModeMenu_change_override();
} else {
document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF');
}
}
}
let PostModeMenu_click_original = null;
function PostModeMenu_click_override(post_id) {
if (PostModeMenu_click_original(post_id))
return true; // view mode, let it click
const mode_dropdown = document.getElementById('mode');
if (!called_add_mode_options) return false; // not logged in or no tag script permission
const s = mode_dropdown.value;
if (s === 'choose-parent') {
set_cookie('chosen-parent', post_id);
mode_dropdown.value = 'set-parent';
PostModeMenu_change_override();
} else if (s === 'set-parent') {
const parent_id = get_cookie('chosen-parent');
unsafeWindow.TagScript.run(post_id, 'parent:' + parent_id + (config.setparent_deletepotentialduplicate ? ' -potential_duplicate' : ''));
}
return false;
}
/***********************/
/* post page functions */
/***********************/
let post_parent_id = null; // input elem
// original post/parent ids
let post_id = null;
let parent_id = null;
let image_data = null;
let resize_timer;
let tag_update_timer;
let tags_changed = false;
// set by find_actions_list():
let actions_ul = null;
let found_delete_action = false;
let mouse_moved = false; // for tag_menu_scaler
function tag_menu_scaler_mousedown(e) {
e.preventDefault();
mouse_moved = false;
window.addEventListener('mousemove', tag_menu_scaler_mousemove);
window.addEventListener('mouseup', tag_menu_scaler_mouseup);
}
function tag_menu_scaler_mousemove(e) {
e.preventDefault();
mouse_moved = true;
set_tag_menu_scale(e, false);
}
function tag_menu_scaler_mouseup(e) {
e.preventDefault();
if (mouse_moved) set_tag_menu_scale(e, true);
window.removeEventListener('mousemove', tag_menu_scaler_mousemove);
window.removeEventListener('mouseup', tag_menu_scaler_mouseup);
}
function set_tag_menu_scale(e, save) {
const tag_menu = document.getElementById('tag_menu');
if (tag_menu === null) return;
const yFromBottom = window.innerHeight - e.clientY;
let yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight));
yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + '%';
tag_menu.style.height = yPercentfromBottom;
if (save) save_setting('tag_menu_scale', yPercentfromBottom);
}
function add_tag_menu() {
if (document.getElementById('post_tags') === null) return; // not logged in
const tag_menu = document.createElement('DIV');
tag_menu.id = 'tag_menu';
tag_menu.style.display = 'none';
tag_menu.style.width = '100%';
tag_menu.style.height = config.tag_menu_scale;
tag_menu.style.position = 'fixed';
tag_menu.style.bottom = '0';
tag_menu.style.overflow = 'auto';
tag_menu.style.backgroundColor = get_original_background_color();
tag_menu.style.zIndex = '10001';
document.body.appendChild(tag_menu);
// the inner div ensures tag_menu_close button doesn't scroll with the content
tag_menu.innerHTML = '<div style="width: calc(100% - 2px); height: 100%; overflow: auto;"><span id="common_tags"></span>current tags:<span id="current_tags"></span></div>';
const tag_menu_scaler = document.createElement('DIV');
tag_menu_scaler.id = 'tag_menu_scaler';
tag_menu_scaler.style.width = '100%';
tag_menu_scaler.style.height = '6px';
tag_menu_scaler.style.backgroundColor = shifted_backgroundColor(32);
tag_menu_scaler.style.position = 'absolute';
tag_menu_scaler.style.top = '0';
tag_menu_scaler.style.cursor = 'ns-resize';
tag_menu_scaler.style.zIndex = '10000';
tag_menu_scaler.addEventListener('mousedown', tag_menu_scaler_mousedown);
tag_menu.appendChild(tag_menu_scaler);
tag_menu.style.paddingTop = tag_menu_scaler.style.height; // since tag_menu_scaler floats above the tags
const create_tag_menu_button = function(id, text) {
const button = document.createElement('DIV');
button.id = id;
button.style.border = '1px solid ' + shifted_backgroundColor(32);
button.style.width = '24px';
button.style.height = '24px';
button.style.position = 'absolute';
button.style.textAlign = 'center';
button.style.cursor = 'pointer';
button.style.backgroundColor = shifted_backgroundColor(16);
button.innerHTML = `<span style="width: 100%; display: block; position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);">${text}</span>`;
button.style.zIndex = '10001';
return button;
};
const tag_menu_close = create_tag_menu_button('tag_menu_close', 'X');
tag_menu_close.style.top = '0';
tag_menu_close.style.right = '0';
tag_menu_close.onclick = () => { show_tag_menu(false); return false; };
tag_menu.appendChild(tag_menu_close);
const tag_menu_open = create_tag_menu_button('tag_menu_open', '«');
tag_menu_open.style.position = 'fixed';
tag_menu_open.style.right = '0';
tag_menu_open.style.bottom = '0';
tag_menu_open.onclick = () => { show_tag_menu(true); update_tag_menu(); return false; };
document.body.appendChild(tag_menu_open);
const tag_menu_save = create_tag_menu_button('tag_menu_save', 'Save changes');
tag_menu_save.style.top = '0';
tag_menu_save.style.right = '36px';
tag_menu_save.style.width = '140px';
tag_menu_save.style.fontWeight = 'bold';
tag_menu_save.addEventListener('click', (e) => {
e.preventDefault();
if (tags_submit_listener())
document.getElementById('edit-form').submit();
});
tag_menu.appendChild(tag_menu_save);
}
function update_tag_menu(skip_common_tags = false) {
if (document.getElementById('post_tags') === null) return; // not logged in
const common_tags_elem = document.getElementById('common_tags');
const current_tags_elem = document.getElementById('current_tags');
// tag menu disabled
if (common_tags_elem === null || current_tags_elem === null)
return;
if (config.tag_menu_layout === 1) {
common_tags_elem.style.display = 'grid';
common_tags_elem.style.gridTemplateColumns = 'fit-content(5%) auto';
} else {
common_tags_elem.style.display = '';
}
const create_tag_button = function(tag, skip_common_tags_update = false) {
const a = document.createElement('A');
a.href = '#';
a.style.paddingLeft = '5px';
a.style.paddingRight = '5px';
a.style.borderStyle = 'solid';
a.style.borderWidth = '1px';
a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons
a.className = (tag_is_present(tag) ? '' : 'tag_nonexistent');
a.onclick = function() {
if (tag_is_present(tag)) {
remove_tag(tag, skip_common_tags_update);
a.className = 'tag_nonexistent';
} else {
add_tag(tag, skip_common_tags_update);
a.className = '';
}
return false;
};
a.innerText = tag;
return a;
};
const wrap_in_div = function(el, margin) {
const div = document.createElement('DIV');
div.style.margin = margin;
div.style.float = 'left';
div.appendChild(el);
return div;
};
const create_top_level_div = function(margin = '3px') {
const div = document.createElement('DIV');
div.style.margin = margin;
return div;
};
const create_tag_list = function() {
const div = document.createElement('DIV');
div.style.display = 'flex';
div.style.flexWrap = 'wrap';
div.style.alignContent = 'flex-start';
div.style.alignItems = 'flex-start';
div.style.margin = '0';
div.style.padding = '0';
return div;
};
// generate tag button list for current tags
const current_tags_flex = create_tag_list();
current_tags_flex.style.marginBottom = '3px';
const current_tags = get_tags_array();
for (const current_tag of current_tags) {
const div = create_top_level_div();
div.appendChild(create_tag_button(current_tag, false));
current_tags_flex.appendChild(div);
}
// replace current list with new one
while (current_tags_elem.hasChildNodes())
current_tags_elem.removeChild(current_tags_elem.lastChild);
current_tags_elem.appendChild(current_tags_flex);
// don't rebuild the common tags list when common tags buttons are pressed
if (skip_common_tags) return;
// now add common tags
// common_tags_json(_idol) should be an array of objects with an optional string "name" field and an array "tags" field,
// where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group)
// or arrays of array containing one string (representing a table)
// ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ]
let tag_data;
try {
tag_data = JSON.parse(config[COMMON_TAGS_KEY]);
} catch (error) {
show_notice(console.error, 'addon error: "common tags" JSON syntax error', error);
return;
}
if (!Array.isArray(tag_data)) {
show_notice(console.error, 'addon error: "common tags" needs to be an array of objects');
return;
}
while (common_tags_elem.hasChildNodes())
common_tags_elem.removeChild(common_tags_elem.lastChild);
for (let k = 0; k < tag_data.length; k++) {
const list_flex = create_tag_list();
const list_name = tag_data[k].name;
const list_tags = tag_data[k].tags;
if (!Array.isArray(list_tags)) {
show_notice(console.error, 'addon error: a "common tags" object needs to have a "tags" array');
return;
}
const TAGS_TYPES = {
LIST: 'list', // e.g. "tag1 tag2"
GROUP: 'group', // e.g. ["tag1 tag2"]
TABLE: 'table' // e.g. [["tag1 tag2"], ["tag3 tag4"]]
};
const group_style = function(el) {
// red in darkmode needs more contrast
const rgb = rgb_to_array(get_original_background_color());
if (is_darkmode()) {
el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, 96));
el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64));
} else {
el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, -64));
el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32));
}
};
for (const list_tag of list_tags) {
const is_array = Array.isArray(list_tag);
// find tags_type
let tags_type;
if (is_array) {
if (list_tag.length === 0) {
show_notice(console.error, 'addon error: "common tags" "tags" array contains an empty array');
return;
}
// check what the array consists of
let all_arrays = true;
let no_arrays = true;
for (let i = 0; i < list_tag.length; i++) {
if (!Array.isArray(list_tag[i])) {
all_arrays = false;
} else {
no_arrays = false;
}
}
if (all_arrays) {
tags_type = TAGS_TYPES.TABLE;
} else if (no_arrays) {
tags_type = TAGS_TYPES.GROUP;
} else {
show_notice(console.error, 'addon error: "common tags" "tags" array contains an array which is neither a group nor a table');
return;
}
} else {
tags_type = TAGS_TYPES.LIST;
}
if (tags_type === TAGS_TYPES.TABLE) {
const tags_table = [];
for (let j = 0; j < list_tag.length; j++) {
if (list_tag[j].length !== 1) {
show_notice(console.error, 'addon error: "common tags" "tags" array contains a table entry with not exactly 1 tags string');
return;
}
tags_table.push(list_tag[j][0].trim().split(/\s+/));
}
const table_height = tags_table.length;
let table_width = 0;
for (let row = 0; row < tags_table.length; row++)
table_width = Math.max(table_width, tags_table[row].length);
// div (flexbox)><div><table><tr><td><div (button)>
const table = document.createElement('TABLE');
table.style.display = 'inline-block';
group_style(table);
table.style.marginBottom = '0';
for (let row = 0; row < table_height; row++) {
const tr = document.createElement('TR');
for (let col = 0; col < table_width; col++) {
const td = document.createElement('TD');
td.style.border = 'none';
td.style.padding = '0';
if (tags_table[row][col])
td.appendChild(wrap_in_div(create_tag_button(tags_table[row][col], true), '1px'));
tr.appendChild(td);
}
table.appendChild(tr);
}
const div = create_top_level_div('0 3px 0 3px');
div.appendChild(table);
list_flex.appendChild(div);
} else if (tags_type === TAGS_TYPES.GROUP) {
if (list_tag.length !== 1) {
show_notice(console.error, 'addon error: "common tags" "tags" array contains a group with not exactly 1 tags string');
return;
}
const tags = list_tag[0].trim().split(/\s+/);
// <div (flexbox)><div><div (button)>
const group_div = document.createElement('DIV');
group_div.style.display = 'inline-block';
group_style(group_div);
for (const tag of tags)
group_div.appendChild(wrap_in_div(create_tag_button(tag, true), '3px'));
const div = create_top_level_div('0 3px 0 3px');
div.appendChild(group_div);
list_flex.appendChild(div);
} else /* if (tags_type === tag_types.LIST) */ {
// <div (flexbox)><div><div (button)>
const tags = list_tag.trim().split(/\s+/);
for (const tag of tags) {
const div = create_top_level_div('4px 3px 2px 3px');
div.appendChild(wrap_in_div(create_tag_button(tag, true)));
list_flex.appendChild(div);
}
}
}
const span = document.createElement('SPAN');
span.innerText = (list_name ? `${list_name}:` : '');
span.style.paddingTop = '2px';
if (list_name) span.style.marginLeft = '2px';
if (list_name && config.tag_menu_layout === 1) {
const add_top_border = function(el) {
el.style.borderTopWidth = '1px';
el.style.borderTopStyle = 'solid';
el.style.borderTopColor = shifted_backgroundColor(32);
};
add_top_border(span);
add_top_border(list_flex);
}
common_tags_elem.appendChild(span);
common_tags_elem.appendChild(list_flex);
}
}
function show_tag_menu(bool) {
document.getElementById('tag_menu').style.display = (bool ? '' : 'none');
document.getElementById('tag_menu_open').style.display = (!bool ? '' : 'none');
}
function add_tags_change_listener() {
const post_tags_area = document.getElementById('post_tags');
if (post_tags_area === null) return; // not logged in
post_tags_area.addEventListener('input', () => {
tags_changed = true;
clearTimeout(tag_update_timer);
tag_update_timer = setTimeout(update_tag_elements, 500);
});
}
// also used for 'tag_menu_save' button
function tags_submit_listener() {
delete_useless_tags_tag();
return true; // actually submit?
}
function add_tags_submit_listener() {
const edit_form = document.getElementById('edit-form');
if (edit_form === null) return; // not logged in
edit_form.addEventListener('submit', (e) => {
if (!tags_submit_listener())
e.preventDefault();
});
}
function find_actions_list() {
const li = document.getElementById('add-to-pool');
if (li === null) return;
actions_ul = li.parentElement;
if (actions_ul === null) return;
const action_links = actions_ul.getElementsByTagName('A');
for (const action_link of action_links) {
if (action_link.innerText === 'Delete') {
found_delete_action = true;
break;
}
}
}
function add_addon_actions() {
if (actions_ul === null) {
show_notice(console.error, 'addon error: couldn\'t find actions list! Disabled addon actions.');
return;
}
const separator = document.createElement('H5');
separator.innerText = 'Addon actions';
const newli = document.createElement('LI');
newli.appendChild(separator);
actions_ul.appendChild(newli);
const add_action = function(func, name, id) {
const a = document.createElement('A');
a.href = '#';
a.onclick = () => { func(); return false; };
a.innerText = name;
const li = document.createElement('LI');
li.id = id;
li.appendChild(a);
actions_ul.appendChild(li);
};
add_action(() => { scale_image(SCALE_MODES.FIT, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image', 'scale-image-fit');
add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor');
add_action(() => { scale_image(SCALE_MODES.VERTICAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)', 'scale-image-ver');
add_action(() => { scale_image(SCALE_MODES.RESET, true); scroll_to_image(config.scroll_to_image_center); }, 'Reset image size', 'reset-image');
if (parent_id === null) return; // not logged in
if (post_id === null) {
show_notice(console.error, 'addon error: couldn\'t find post id?! Flag duplicate feature disabled.');
return;
}
add_action(() => { flag_duplicate(post_id, ''); }, 'Flag duplicate', 'flag-duplicate');
add_action(() => { flag_duplicate(post_id, ', visually identical'); }, 'Flag duplicate (identical)', 'flag-duplicate-identical');
add_action(() => { flag_duplicate(post_id, ' with worse quality'); }, 'Flag duplicate (quality)', 'flag-duplicate-quality');
add_action(() => { flag_duplicate(post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution');
}
function add_tag_buttons() {
const edit_form = document.getElementById('edit-form');
if (edit_form === null) return; // not logged in
const button_place = edit_form.children[1].children[0].children[0].children[0];
button_place.style.whiteSpace = 'nowrap';
{
const el = document.createElement('BUTTON');
el.id = 'clear_parent_id_button';
el.style.margin = '0 3px 0 6px';
el.innerText = 'Clear';
el.onclick = () => { post_parent_id.clear(); return false; };
post_parent_id.parentNode.appendChild(el);
}
{
const el = document.createElement('BUTTON');
el.id = 'reset_parent_id_button';
el.style.margin = '0 3px';
el.innerText = 'Reset';
el.onclick = () => { reset_parent_id(); return false; };
post_parent_id.parentNode.appendChild(el);
}
{
const el = document.createElement('BUTTON');
el.id = 'tag_reset_button';
el.style.margin = '0 3px 0 6px';
el.innerText = 'Reset';
el.onclick = () => { reset_tags(); return false; };
button_place.appendChild(el);
}
{
const el = document.createElement('BUTTON');
el.id = 'tag_dup_button';
el.style.margin = '0 3px';
button_place.appendChild(el);
}
{
const el = document.createElement('BUTTON');
el.id = 'tag_var_button';
el.style.margin = '0 3px';
button_place.appendChild(el);
}
{
const el = document.createElement('BUTTON');
el.id = 'tag_pot_button';
el.style.margin = '0 3px';
button_place.appendChild(el);
}
}
function update_tag_buttons() {
const taglist = document.getElementById('post_tags');
const dup_button = document.getElementById('tag_dup_button');
const var_button = document.getElementById('tag_var_button');
const pot_button = document.getElementById('tag_pot_button');
if (taglist === null || dup_button === null || var_button === null || pot_button === null)
return;
const tags = get_tags_array();
if (!tags.includes('duplicate')) {
dup_button.onclick = function() { add_tag('duplicate'); return false; };
dup_button.innerText = 'Tag duplicate';
} else {
dup_button.onclick = function() { remove_tag('duplicate'); return false; };
dup_button.innerText = 'Untag duplicate';
}
if (!tags.includes('legitimate_variation')) {
var_button.onclick = function() { add_tag('legitimate_variation'); return false; };
var_button.innerText = 'Tag legitimate_variation';
} else {
var_button.onclick = function() { remove_tag('legitimate_variation'); return false; };
var_button.innerText = 'Untag legitimate_variation';
}
pot_button.innerText = 'Untag potential_duplicate';
if (!tags.includes('potential_duplicate')) {
pot_button.disabled = true;
} else {
pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; };
pot_button.disabled = false;
}
}
function reset_parent_id() {
post_parent_id.value = parent_id;
}
function get_old_tags_array() {
return document.getElementById('post_old_tags').value.trim().split(/\s+/);
}
function get_tags_array() {
return document.getElementById('post_tags').value.trim().split(/\s+/);
}
function add_tag(tag, skip_common_tags_update = false) {
const tags = get_tags_array();
if ((tag === 'duplicate' && tags.includes('legitimate_variation')) || (tag === 'legitimate_variation' && tags.includes('duplicate'))) {
show_notice(console.warn, 'addon: cannot tag as duplicate and legitimate_variation at the same time.');
return;
}
if (tags.includes(tag)) {
show_notice(console.warn, 'addon: tag already present');
return;
}
document.getElementById('post_tags').value += ' ' + tag;
tags_changed = true;
update_tag_elements(skip_common_tags_update);
}
function remove_tag(tag, skip_common_tags_update = false) {
const tags = get_tags_array();
for (let i = 0; i < tags.length; i++)
if (tags[i] === tag)
tags[i] = '';
document.getElementById('post_tags').value = tags.join(' ').trim();
tags_changed = true;
update_tag_elements(skip_common_tags_update);
}
function delete_useless_tags_tag() {
if (tags_changed && config.editform_deleteuselesstags) remove_tag('useless_tags');
}
function tag_is_present(tag) {
return get_tags_array().includes(tag);
}
function reset_tags() {
document.getElementById('post_tags').value = document.getElementById('post_old_tags').value;
tags_changed = false;
update_tag_elements();
}
function update_tag_elements(skip_common_tags = false) {
update_tag_buttons();
const tag_menu = document.getElementById('tag_menu');
if (tag_menu !== null && tag_menu.style.display !== 'none') {
update_tag_menu(skip_common_tags);
}
}
// flag option with default text
function flag_duplicate(id, reason_suffix) {
if (IS_GREASEMONKEY4) {
show_notice(console.warn, 'addon: \'Flag duplicate\' not yet supported in Greasemonkey');
return;
}
if (parent_id === null) {
show_notice(console.warn, 'addon: parent id not found, not logged in?');
return;
}
const current_parent_id = post_parent_id.value;
if (current_parent_id !== parent_id) {
show_notice(console.warn, 'addon: parent id was changed but not saved!');
return;
}
if (!current_parent_id || current_parent_id.length === 0) {
show_notice(console.warn, 'addon: no parent id set!');
return;
}
const tags = get_tags_array();
const old_tags = get_old_tags_array();
if (tags.includes('duplicate') && !old_tags.includes('duplicate')) {
show_notice(console.warn, 'addon: duplicate tag set but not saved!');
return;
}
if (!old_tags.includes('duplicate')) {
show_notice(console.warn, 'addon: not tagged as duplicate!');
return;
}
if (old_tags.includes('legitimate_variation') || old_tags.includes('revision'))
if (!window.confirm('Post is tagged as a legitimate_variation or revision, it may not be a duplicate!\n\nFlag it anyway?'))
return;
const reason = window.prompt('Why should this post be reconsidered for moderation?', `duplicate of ${parent_id}${reason_suffix}`);
if (reason === null)
return;
// TODO will not work on Greasemonkey at all
new unsafeWindow.Ajax.Request('/post/flag.json', {
parameters: { id, reason },
onComplete(response) {
const resp = response.responseJSON;
if (resp.success) show_notice(console.log, 'Post was resent to moderation queue');
else show_notice(console.error, `Error: ${resp.reason}`);
}
});
}
function read_image_data() {
const data = {
img_elem: null, // <img>, <video> or <object> (in case of flash)
emb_elem: null, non_img_div: null, // flash is <object><embed>, we need the <div> it's in as well
is_flash: false,
width: null,
height: null,
aspect_ratio: null,
current_height: null // store current height separately, because flash is a bitch
};
// image or video
const img = document.getElementById('image');
if (img !== null) {
data.img_elem = img;
// workaround for Galinoa's Sankaku Channel Dark: don't read .width/.height attributes but read "Details"
let res = null;
const lowres = document.getElementById('lowres');
if (lowres !== null) {
res = lowres.innerText.split('x'); // parse "<width>x<height>"
} else {
const highres = document.getElementById('highres');
if (highres !== null) {
res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)"
}
}
if (res === null) {
show_notice(console.error, 'addon error: image/video resolution not in "Details"?! Disabled scaling.');
return null;
}
data.width = res[0];
data.height = res[1];
data.aspect_ratio = data.width / data.height;
return data;
}
// flash or unknown
const non_img = document.getElementById('non-image-content');
if (non_img !== null) {
data.non_img_div = non_img;
const objs = non_img.getElementsByTagName('OBJECT');
const embs = non_img.getElementsByTagName('EMBED');
data.is_flash = (objs.length === 1 && embs.length === 1); // <object><embed>
if (!data.is_flash) {
show_notice('addon error: unknown post content! Can\'t read width/height.');
return null;
}
data.img_elem = objs[0];
data.emb_elem = embs[0];
// <object> contains width/height in both Firefox and Chrome
data.width = data.img_elem.width;
data.height = data.img_elem.height;
data.current_height = data.height;
data.aspect_ratio = data.width / data.height;
return data;
}
return null;
}
const SCALE_MODES = { RESET: -1, FIT: 0, HORIZONTAL: 1, VERTICAL: 2 };
// stretch image/video/flash, requires data from read_image_data()
function scale_image(mode, always_scale) {
if (image_data === null) return; // read_image_data() failed
if (!always_scale && (!config.scale_flash && image_data.is_flash))
return;
const note_container = document.getElementById('note-container');
// reset image size
if (mode === SCALE_MODES.RESET) {
if (!image_data.is_flash) {
image_data.img_elem.style.width = null;
image_data.img_elem.style.height = null;
if (note_container !== null) note_container.style.transform = '';
} else {
image_data.img_elem.width = image_data.width;
image_data.img_elem.height = image_data.height;
image_data.emb_elem.width = image_data.width;
image_data.emb_elem.height = image_data.height;
}
image_data.current_height = image_data.height;
// workaround for Galinoa's Sankaku Channel Dark
if (config.sankaku_channel_dark_compatibility) {
image_data.img_elem.style.paddingLeft = '';
note_fix();
}
return;
}
// workaround for Galinoa's Sankaku Channel Dark
// problem: seems to only work for bigger windows
let left_side;
if (config.sankaku_channel_dark_compatibility) {
const sidebar = document.getElementsByClassName('sidebar')[0];
left_side = (sidebar.getBoundingClientRect().right + window.pageXOffset + 12);
image_data.img_elem.style.paddingLeft = left_side + 'px'; // don't hide behind sidebar
} else {
left_side = image_data.img_elem.getBoundingClientRect().left + window.pageXOffset;
}
const target_w = Math.max(window.innerWidth - left_side - get_scrollbar_width() - 1, 1);
const target_h = Math.max(window.innerHeight - 1, 1);
const target_aspect_ratio = target_w / target_h;
if (mode === SCALE_MODES.FIT)
mode = (image_data.aspect_ratio > target_aspect_ratio ? SCALE_MODES.HORIZONTAL : SCALE_MODES.VERTICAL);
let new_width, new_height;
if (mode === SCALE_MODES.HORIZONTAL) {
new_width = Math.floor(target_w);
new_height = Math.floor(target_w / image_data.aspect_ratio);
} else if (mode === SCALE_MODES.VERTICAL) {
new_width = Math.floor(target_h * image_data.aspect_ratio);
new_height = Math.floor(target_h);
}
if (!always_scale && (config.scale_only_downscale && (new_width > image_data.width || new_height > image_data.height)))
return;
// We sadly can't use transform scale here because it doesn't change the DOM size,
// so the post content would cover up (or be covered up by) the elements below it
const set_dimensions = (obj) => {
obj.width = new_width + 'px';
obj.height = new_height + 'px';
};
if (image_data.is_flash) {
set_dimensions(image_data.img_elem);
set_dimensions(image_data.emb_elem);
} else {
set_dimensions(image_data.img_elem.style);
// For notes we need to use transform scale because style.width/height/top/left
// is exactly what gets stored on the server
if (note_container !== null) {
note_container.style.transformOrigin = '0 0';
note_container.style.transform = `scale(${new_width / image_data.width})`;
}
}
image_data.current_height = new_height;
}
function scale_on_resize_helper() {
clearTimeout(resize_timer);
resize_timer = setTimeout(() => {
if (config.scale_on_resize) scale_image(config.scale_mode, false);
}, 100);
}
function add_scale_on_resize_listener() {
window.addEventListener('resize', scale_on_resize_helper);
}
function remove_scale_on_resize_listener() {
window.removeEventListener('resize', scale_on_resize_helper);
}
function scroll_to_image(to_center) {
if (image_data === null) return;
const absolute_img_top = (image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect().top + window.pageYOffset;
if (to_center) {
const top_of_centered_rect = absolute_img_top - (window.innerHeight - image_data.current_height) / 2;
window.scrollTo(0, top_of_centered_rect);
} else {
window.scrollTo(0, absolute_img_top);
}
}
// when resize notice is hidden (e.g. original image is loaded), scroll to make up the difference
function add_resize_notice_listener() {
const resized_notice = document.getElementById('resized_notice');
if (image_data === null || resized_notice === null) return;
const notice_y_diff = image_data.img_elem.getBoundingClientRect().top - resized_notice.getBoundingClientRect().top;
const observer = new MutationObserver(() => {
observer.disconnect();
window.scrollBy(0, -notice_y_diff);
});
observer.observe(resized_notice, {
attributes: true,
attributeFilter: ['style'],
});
}
// simple note fix for Galinoa's Sankaku Channel Dark (only for default image size)
function note_fix() {
const note_container = document.getElementById('note-container');
if (note_container !== null && image_data !== null) {
note_container.style.marginLeft = ((window.innerWidth - image_data.img_elem.clientWidth) / 2 - 8) + 'px';
}
}
function add_postpage_hotkeys() {
document.addEventListener('keydown', (e) => {
const tag = e.target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
if (e.ctrlKey || e.altKey || e.shiftKey) return;
switch (e.key) {
case 'r': // r(eset)
scale_image(SCALE_MODES.RESET, true);
scroll_to_image(config.scroll_to_image_center);
break;
case 'f': // f(it)
scale_image(SCALE_MODES.FIT, true);
scroll_to_image(config.scroll_to_image_center);
break;
case 'g': // g
scale_image(SCALE_MODES.HORIZONTAL, true);
scroll_to_image(config.scroll_to_image_center);
break;
case 'h': // h
scale_image(SCALE_MODES.VERTICAL, true);
scroll_to_image(config.scroll_to_image_center);
break;
case 's': // s(imilar)
if (post_id === null) {
show_notice(console.error, 'addon error: couldn\'t find post id?!');
} else {
open_in_tab(window.location.origin + '/post/similar?id=' + post_id);
}
break;
case 'd': // d(elete)
if (post_id === null) {
show_notice(console.error, 'addon error: couldn\'t find post id?!');
} else if (!found_delete_action) {
show_notice(console.error, 'addon error: Delete action not found, no permission?');
} else {
open_in_tab(window.location.origin + '/post/delete/' + post_id);
}
break;
}
}, true);
}
/***********************/
/* wiki page functions */
/***********************/
function add_dtext_style_buttons() {
const wiki_form = document.getElementById('wiki-form');
const wiki_body = document.getElementById('wiki_page_body');
if (wiki_form === null || wiki_body === null) {
show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", DText style buttons disabled');
return;
}
wiki_form.style.display = 'flex'; // add buttons to the right
const div = document.createElement('DIV');
div.style.marginLeft = '1em';
div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top) + 'px';
div.style.background = get_original_background_color();
div.innerHTML = '<label title="Buttons add (or remove outermost) DText styling on selected text.\n'
+ 'Buttons have accesskeys/keyboard shortcuts, typically activated by Alt + Shift + key or Alt + key depending on the browser."'
+ ' style="cursor:help;">DText style buttons</label><br>';
const DTEXT_CODES = [
'b', 'i', 's', 'u', '<br>',
'code', 'quote', 'spoiler', '<br>',
'[[...]]', '{{...}}', 'URL',
];
for (const code of DTEXT_CODES) {
if (code === '<br>') {
div.appendChild(document.createElement('BR'));
continue;
}
const btn = document.createElement('BUTTON');
btn.style.cursor = 'pointer';
btn.onclick = () => {
const text = wiki_body.value;
const a = wiki_body.selectionStart;
const b = wiki_body.selectionEnd;
const selection = text.substring(a, b);
let cut_a = a, cut_b = b; // range to cut
let insert; // text to insert inbetween
let sel_off_a = 0, sel_off_b = 0; // by default the inserted text is selected, these are offsets
if (code === 'URL') {
const link = window.prompt('Enter URL');
if (link === null) return false;
insert = `"${selection}":${link}`;
} else {
let open = '';
let close = '';
if (code === '{{...}}') {
open = '{{';
close = '}}';
} else if (code === '[[...]]') {
open = '[[';
close = ']]';
} else {
open = `[${code}]`;
close = `[/${code}]`;
}
const ext_a = Math.max(0, a - open.length);
const ext_b = b + close.length;
const ext_selection = text.substring(ext_a, ext_b);
if (ext_selection.startsWith(open) && ext_selection.endsWith(close)) {
// remove DText directly outside selection
insert = selection;
cut_a = ext_a;
cut_b = ext_b;
sel_off_a = -open.length;
} else if (selection.startsWith(open) && selection.endsWith(close)) {
// remove DText directly inside selection
insert = selection.slice(open.length, -close.length);
} else {
let cleaned;
const open_i = selection.indexOf(open);
const close_i = selection.lastIndexOf(close);
// remove existing (inner) DText before adding new one
if (open_i !== -1 && close_i > open_i) {
cleaned = selection.substring(0, open_i)
+ selection.substring(open_i + open.length, close_i)
+ selection.substring(close_i + close.length);
} else {
cleaned = selection;
}
insert = open + cleaned + close;
// select 'cleaned'
sel_off_a = open.length;
sel_off_b = -(open.length + close.length);
}
}
const sel_a = a + sel_off_a;
const sel_b = sel_a + insert.length + sel_off_b;
wiki_body.value = text.substring(0, cut_a) + insert + text.substring(cut_b);
wiki_body.setSelectionRange(Math.max(0, sel_a), sel_b);
wiki_body.focus();
return false;
};
// add accesskeys and titles
if (code.length === 1) {
btn.accessKey = code;
btn.title =
code === 'b' ? 'bold' :
code === 'i' ? 'italic' :
code === 's' ? 'strikethrough' :
code === 'u' ? 'underline' : '';
} else {
btn.accessKey =
code === 'code' ? 'c' :
code === 'quote' ? 'q' :
code === 'spoiler' ? 'o' :
code === '[[...]]' ? 'w' :
code === '{{...}}' ? 'p' :
code === 'URL' ? 'l' : '';
if (code === '[[...]]') btn.title = 'accesskey: w';
if (code === '{{...}}') btn.title = 'accesskey: p';
}
const u = // character to underline
code === 'code' ? 0 :
code === 'quote' ? 0 :
code === 'spoiler' ? 2 :
code === 'URL' ? 2 :
code.length === 1 ? 0 : -1;
if (u !== -1) {
btn.innerHTML = `${code.substring(0, u)}<span style="text-decoration: underline;">${code[u]}</span>${code.substring(u + 1)}`;
} else {
btn.innerText = code;
}
div.appendChild(btn);
}
wiki_form.appendChild(div);
}
function add_wiki_template() {
if (config.wiki_template.length === 0) return;
const wiki_form = document.getElementById('wiki-form');
const wiki_body = document.getElementById('wiki_page_body');
if (wiki_form === null || wiki_body === null) {
show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", wiki template disabled');
return;
}
wiki_form.style.display = 'flex'; // add template to the right
const div = document.createElement('DIV');
div.style.marginLeft = '1em';
const template_label = document.createElement('LABEL');
template_label.innerText = 'Wiki Template';
template_label.style.cursor = 'help';
template_label.style.textDecoration = 'underline dashed';
template_label.title = 'Selected text can be appended to the page body. Clicking or using arrow keys selects a whole line, pressing \'c\' or the button below copies the selection over';
const template_text = document.createElement('TEXTAREA');
template_text.id = 'wiki_template_text';
template_text.cols = wiki_body.cols;
template_text.rows = wiki_body.rows;
template_text.style.width = '33em';
template_text.value = config.wiki_template;
const insert_template_selection = () => {
const text = template_text.value;
const a = template_text.selectionStart;
const b = template_text.selectionEnd;
const selection = text.substring(a, b);
const add_newline = wiki_body.value && !wiki_body.value.endsWith('\n');
wiki_body.value += (add_newline ? '\n' : '') + selection;
};
const extend_selection = () => {
// extend empty selection to newlines (or text start/end)
const text = template_text.value;
let a = template_text.selectionStart;
let b = template_text.selectionEnd;
if (a === b) {
const ext_a = text.lastIndexOf('\n', a - 1);
a = (ext_a !== -1 ? ext_a + 1 : 0);
if (text.charAt(b) !== '\n') {
const ext_b = text.indexOf('\n', b + 1);
b = (ext_b !== -1 ? ext_b - 1 : text.length - 1) + 1;
}
template_text.setSelectionRange(a, b);
}
};
template_text.readOnly = true; // hides the caret and there's no easy workaround
template_text.addEventListener('click', extend_selection);
template_text.addEventListener('keyup', extend_selection);
template_text.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.key === 'c') insert_template_selection();
});
const btn = document.createElement('BUTTON');
btn.innerText = 'Copy selection over';
btn.style.cursor = 'pointer';
btn.style.fontWeight = 'bold';
btn.style.padding = '0.2em 2em';
btn.style.margin = '0.1em';
btn.onclick = () => { insert_template_selection(); template_text.focus(); return false; };
div.appendChild(template_label);
div.appendChild(document.createElement('BR'));
div.appendChild(template_text);
div.appendChild(document.createElement('BR'));
div.appendChild(btn);
wiki_form.appendChild(div);
div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top - template_label.getBoundingClientRect().height) + 'px';
}
function add_tag_edit_gear() {
try {
// add a "⚙" link to the edit tag page
const h2 = document.getElementsByClassName('title')[0];
const tag = new URL(window.location.href).searchParams.get('title');
const wiki_edit_link = document.createElement('A');
wiki_edit_link.href = '/tag/edit?name=' + tag;
wiki_edit_link.innerText = '⚙';
wiki_edit_link.title = 'Edit Tag';
h2.appendChild(wiki_edit_link);
} catch (error) {
show_notice(console.error, 'addon error: couldn\'t add "⚙" tag page link, check console', error);
}
}
/******************/
/* document-start */
/******************/
await load_config();
// listen for config changes in other windows
if (USE_MONKEY_STORAGE) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
else if (USE_LOCAL_STORAGE) window.addEventListener('storage', local_storage_changed);
else show_notice(console.error, 'addon error: couldn\'t add storage change listener! No cross-tab communication possible.');
/*************************************/
/* main page / visually similar page */
/*************************************/
// skip language codes in pathnames like "/jp/post/show"
let pathname = window.location.pathname;
if (pathname.indexOf('/', 1) === 3)
pathname = pathname.substring(3);
if (pathname === '/' || pathname === '/post/index' || pathname.startsWith('/post/similar')) {
// try to add new modes right after the "Apply tag script" mode is added to prevent it being reset to "View posts" on page reloads
// it's possible we are too late to observe its construction, so look for it afterwards immediately
const observer = register_observer((node) => {
return (node.value === 'apply-tag-script');
}, (node, observer) => {
observer.disconnect();
// don't pass node.parentNode, because it can be undefined or the <div> above for reasons I cannot understand
add_mode_options();
return true;
});
const dropdown = document.getElementById('mode');
if (dropdown !== null) {
for (const child of dropdown.children) {
if (child.value === 'apply-tag-script') { // it's already there
observer.disconnect(); // stop looking for it
add_mode_options(dropdown);
break;
}
}
}
// add thumbnail icons and fade out thumbnails for dynamically loaded posts (from auto paging)
add_thumbnail_observer((node) => (node.classList != null && node.classList.contains('content-page')));
/*************/
/* post page */
/*************/
} else if (pathname.startsWith('/post/show/')) {
// mute/pause videos
const observer = register_observer((node) => {
return (node.id === 'image');
}, (node, observer) => {
configure_video(node);
return true;
});
const video = document.getElementById('image');
if (video !== null) {
observer.disconnect();
configure_video(video);
}
add_thumbnail_observer((node) => (node.id === 'recommendations'));
/*************/
/* user page */
/*************/
} else if (pathname.startsWith('/user/show/')) {
add_thumbnail_observer((node) => (node.id === 'recommendations'));
}
/******************/
/* content-loaded */
/******************/
async function init() {
// sitefix for flagged posts not always showing red border
// problem: "flagged" style is defined before "has-parent" and "has-children" CSS styles, so these two take priority
// fix: just add another copy of the "flagged" style at the end
const css = 'img.has-children {padding:0px;border:2px solid #A7DF38;} img.has-parent {padding:0px;border:2px solid #CCCC00;} img.flagged {padding: 0px; border: 2px solid #F00;}'
+ ' a.tag_nonexistent { color: #E00; }'; // custom style for tag menu
add_style(css);
add_config_dialog();
if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C');
add_config_button();
update_config_dialog();
update_headerlogo();
// process what the thumbnail observer may have missed
modify_thumbnails(document);
/*************************************/
/* main page / visually similar page */
/*************************************/
if (pathname === '/' || pathname === '/post/index' || pathname.startsWith('/post/similar')) {
if (config.tag_search_buttons) add_tag_search_buttons();
add_postmode_hotkeys();
if (called_add_mode_options) {
// add_mode_options() was called early, as it should
PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page
} else {
// if not, catch up on it later
call_postmodemenu_init_workaround = true;
}
if (!IS_GREASEMONKEY4) {
// try to guarantee sitescript has loaded
while (unsafeWindow.PostModeMenu === undefined)
await sleep(100);
PostModeMenu_click_original = unsafeWindow.PostModeMenu.click;
// TODO will not work on Greasemonkey (need to replace click events just like with the mode change event)
unsafeWindow.PostModeMenu.click = PostModeMenu_click_override;
}
/*************/
/* post page */
/*************/
} else if (pathname.startsWith('/post/show/')) {
const hidden_post_id_el = document.getElementById('hidden_post_id');
if (hidden_post_id_el !== null) {
post_id = hidden_post_id_el.innerText;
} else {
const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
post_id = temp.substring(temp.lastIndexOf('/') + 1);
}
post_parent_id = document.getElementById('post_parent_id');
if (post_parent_id !== null)
parent_id = post_parent_id.value;
if (config.view_history_enabled) {
config[HISTORY_KEY].add(Number(post_id));
save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
}
if (config.tag_search_buttons) add_tag_search_buttons();
find_actions_list();
add_addon_actions();
add_tag_buttons();
if (config.tag_menu) add_tag_menu();
update_tag_elements(); // initialize tag menu/buttons
add_tags_change_listener();
add_tags_submit_listener();
image_data = read_image_data();
if (config.scale_image) scale_image(config.scale_mode, false);
if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center);
add_resize_notice_listener();
if (config.sankaku_channel_dark_compatibility) note_fix();
add_postpage_hotkeys();
if (config.scale_on_resize) add_scale_on_resize_listener();
/*************/
/* wiki page */
/*************/
} else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) {
add_dtext_style_buttons();
add_wiki_template();
} else if (pathname.startsWith('/wiki/show')) {
add_tag_edit_gear();
}
}
if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') {
init().catch((reason) => {
show_notice(console.error, 'addon error: init() failed, check console', reason);
});
} else {
document.addEventListener('DOMContentLoaded', init, false);
}
})(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);