// ==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.79
// @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/*
// @match https://legacy.sankakucomplex.com/*
// @run-at document-start
// @noframes
// @grant GM.registerMenuCommand
// @grant GM.addStyle
// @grant GM.openInTab
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @grant GM.addValueChangeListener
// @grant GM_addValueChangeListener
// @grant GM.setClipboard
// @grant unsafeWindow
// ==/UserScript==
(async function(unsafeWindow) {
'use strict';
const VERSION = 'v0.99.79';
const SVG_SIZE = 20;
const SPEAKER_SVG = `<svg class="speaker_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
<g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
<rect width="32" height="2" stroke-width=".074927"/>
<rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
<rect y="2" width="2" height="28" stroke-width=".075839"/>
<rect y="30" width="32" height="2" stroke-width=".07698"/>
</g>
<path d="m19 11c6 5 0 10 0 10" fill="none" stroke="#1cd9ff" stroke-width="2"/>
<path d="m23 9c8 7 0 14 0 14" fill="none" stroke="#1cd9ff" stroke-width="2"/>
<path d="m16 23h-3l-3-3h-5v-8h5l3-3h3" fill="#ff761c"/>
</svg>`;
const ANIMATED_SVG = `<svg class="animated_icon" width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
<g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
<rect width="32" height="2" stroke-width=".074927"/>
<rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
<rect y="2" width="2" height="28" stroke-width=".075839"/>
<rect y="30" width="32" height="2" stroke-width=".07698"/>
</g>
<path d="m18 16-10 6v-12" fill="#ff761c"/>
<path d="m26 16-9 6v-12" fill="#ff761c"/>
</svg>`;
const EXPLICIT_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g>
<rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
<g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
<rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
<rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
</g>
<g transform="scale(.9953 1.0047)" fill="#f99" aria-label="S">
<rect x="12.057" y="5.9718" width="11.052" height="2.9859" stroke-width="1.0856"/>
<rect x="12.057" y="22.892" width="11.052" height="2.9859" stroke-width="1.0856"/>
<rect x="12.057" y="14.432" width="9.0425" height="2.9859" stroke-width=".98198"/>
<rect x="9.0425" y="5.9718" width="3.0142" height="19.906" stroke-width="1.0541"/>
</g>
</g>
</svg>`;
const QUESTIONABLE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g>
<rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
<g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
<rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
<rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
</g>
<g transform="translate(6.9551 5.0704)" fill="#999" aria-label="S">
<path d="m11.545 14.93 5 4-2 2-5-4" fill="#999"/>
</g>
<text x="-13" y="4" font-family="'Andika New Basic'" font-size="40px" style="line-height:1.25" xml:space="preserve"><tspan x="-13" y="4"/></text>
<path d="m16 6.5a7 9.5 0 0 0-7 9.5 7 9.5 0 0 0 7 9.5 7 9.5 0 0 0 7-9.5 7 9.5 0 0 0-7-9.5zm0 1.5a5 8 0 0 1 5 8 5 8 0 0 1-5 8 5 8 0 0 1-5-8 5 8 0 0 1 5-8z" fill="#999"/>
</g>
</svg>`;
const SAFE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<g>
<rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
<g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
<rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
<rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
<rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
</g>
<g transform="scale(.9953 1.0047)" fill="#9f9" aria-label="S">
<path d="m21.586 9.7289q-0.78306-0.39548-1.4501-0.65914-0.65255-0.27684-1.2761-0.44822-0.62355-0.18456-1.2471-0.26366-0.62355-0.079096-1.3196-0.079096-0.87006 0-1.5951 0.23729-0.71055 0.23729-1.2326 0.63277-0.52204 0.39548-0.81206 0.90961-0.27552 0.51413-0.27552 1.0546 0 0.5405 0.15951 0.98871 0.17401 0.43503 0.68155 0.83052 0.50754 0.3823 1.4501 0.7646 0.94257 0.36912 2.4942 0.77779 1.4356 0.3823 2.5377 0.89643 1.1166 0.50095 1.8706 1.1601t1.1456 1.5028q0.39153 0.8437 0.39153 1.9115 0 1.3315-0.58004 2.4256-0.56554 1.081-1.5661 1.872-0.98607 0.77779-2.3057 1.2128-1.3196 0.42185-2.7987 0.42185-1.0441 0-2.0447-0.13183-0.98607-0.13183-1.8706-0.36912-0.87006-0.23729-1.6096-0.55368-0.73955-0.32957-1.2906-0.72506l0.65255-2.7025q1.4501 1.1469 2.9872 1.661 1.5516 0.51413 3.1757 0.51413 0.87006 0 1.6821-0.22411t1.4356-0.64596q0.62355-0.43503 1.0006-1.0546 0.37703-0.63278 0.37703-1.4369 0-0.51413-0.18851-0.97553-0.17401-0.47458-0.72505-0.9228-0.55104-0.44822-1.5806-0.88325-1.0296-0.44822-2.7262-0.90961-1.6966-0.4614-2.7697-1.0151-1.0731-0.55368-1.6821-1.1996-0.60904-0.65914-0.84106-1.3974-0.21752-0.75142-0.21752-1.5951 0-0.8437 0.37703-1.7797 0.39153-0.93598 1.2181-1.7269 0.82656-0.79097 2.1172-1.3051 1.2906-0.52731 3.1032-0.52731 0.84106 0 1.5661 0.065914 0.73956 0.065914 1.4211 0.21092 0.69605 0.14501 1.3631 0.36912 0.66705 0.22411 1.3776 0.5405z" fill="#9f9"/>
</g>
</g>
</svg>`;
const RATING_SVG = {
e: EXPLICIT_SVG,
q: QUESTIONABLE_SVG,
s: SAFE_SVG,
};
// 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 large_breasts huge_breasts gigantic_breasts alternate_bust_size"
]
},
{
"name": "Skin Color",
"tags": [
"pale_skin dark_skin tanned shiny_skin albino",
["red_skin orange_skin yellow_skin green_skin blue_skin purple_skin pink_skin white_skin grey_skin black_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_hair black_hair blue_hair brown_hair green_hair grey_hair orange_hair pink_hair purple_hair red_hair silver_hair white_hair"],
["golden_eyes black_eyes blue_eyes brown_eyes green_eyes grey_eyes orange_eyes pink_eyes purple_eyes red_eyes silver_eyes white_eyes"]
]
]
},
{
"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 \\\\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 embarrassed 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": [
"simple_background gradient_background two-tone_background ambiguous_background blurry_background",
["white_background grey_background black_background red_background brown_background orange_background yellow_background green_background blue_background purple_background pink_background"]
]
},
{
"name": "Placement",
"tags": [
"indoors outdoors rooftop city pool beach cave bedroom hallway"
]
},
{
"name": "Nature",
"tags": [
"ocean river tree palm_tree wisteria lilac grass sand water",
["white_flower red_flower yellow_flower blue_flower purple_flower pink_flower"]
]
},
{
"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"
]
}
]`;
const POST_MODE_DESCRIPTIONS = {
'view': 'View',
'add-fav': 'Add to favorites',
'remove-fav': 'Remove from favorites',
'rating-s': 'Rate G',
'rating-q': 'Rate 15+',
'rating-e': 'Rate R18+',
'approve': 'Approve post',
'flag': 'Flag',
'edit-tag-script': 'Edit tag script',
'apply-tag-script': 'Apply tag script',
'choose-parent': 'Choose Parent',
'set-parent': 'Set Parent',
'edit-tags': 'Edit Tags',
'find-similar': 'Find Similar',
'delete': 'Delete Post',
};
/*****************/
/* compatibility */
/*****************/
let IS_MONKEY = false; // Tampermonkey, Violentmonkey, Greasemonkey (all at least partially support 'GM.' functions)
if (typeof GM === 'object' && typeof GM.info === 'object') {
IS_MONKEY = true;
// Greasemonkey:
// doesn't have addStyle and addValueChangeListener
// fetch() doesn't work with relative URLs (https://github.com/greasemonkey/greasemonkey/issues/2647), workaround: new URL('/relative/path', document.location)
// polyfill for ViolentMonkey
if (!GM.addValueChangeListener && typeof GM_addValueChangeListener !== 'undefined') GM.addValueChangeListener = GM_addValueChangeListener;
}
const HAS_MONKEY_STORAGE = IS_MONKEY;
const HAS_MONKEY_STORAGE_LISTENER = IS_MONKEY && GM.addValueChangeListener;
const HAS_MONKEY_ADD_STYLE = IS_MONKEY && GM.addStyle;
let HAS_LOCAL_STORAGE;
try {
HAS_LOCAL_STORAGE = !!localStorage.getItem;
} catch (error) { // DOMException
HAS_LOCAL_STORAGE = false;
}
function add_storage_change_listener() {
if (HAS_MONKEY_STORAGE_LISTENER) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed);
else if (HAS_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.');
}
function open_in_tab(url) {
if (IS_MONKEY) GM.openInTab(url, false);
else window.open(url); // requires popup permission
}
function add_style(css) {
if (HAS_MONKEY_ADD_STYLE) {
GM.addStyle(css);
} else {
const sheet = document.createElement('STYLE');
sheet.innerText = css;
document.head.appendChild(sheet);
}
}
function set_clipboard(text) {
if (IS_MONKEY) {
GM.setClipboard(text);
} else {
navigator.clipboard.writeText(text).catch((err) => {
show_notice(console.error, '[addon error]: Couldn\'t copy text to clipboard', err);
});
}
}
// 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 to stringify Sets and Hotkeys
function json_replacer(key, value) {
if (typeof value === 'object') {
if (value instanceof Set) return { t: 'Set', v: [...value] };
if (value instanceof Map) return { t: 'Map', v: [...value] };
if (value instanceof Hotkey) {
return { t: 'Hotkey', v: {
modifiers: value.modifiers,
action: value.action_name,
} };
}
}
return value;
}
// enables JSON to parse Sets and Hotkeys
function json_reviver(key, value) {
if (typeof value === 'object') {
switch (value.t) {
case 'Set':
return new Set(value.v);
case 'Map':
return new Map(value.v);
case 'Hotkey':
return new Hotkey(value.v.action, value.v.modifiers);
}
}
return value;
}
let applied_css = false;
function adjust_css() {
if (applied_css) return;
applied_css = true;
// change priority of post borders (by redefining their colors after their original definition)
// original: flagged < has-children < has-parent < pending < deleted
// default: deleted < has-children < has-parent < pending < flagged
// variants: pending < deleted < has-children < has-parent < flagged
// note: pending, flagged and deleted are mutually exclusive, so this is only about their relation to has-children/parent
// also note: deleted posts only show through explicit search so they don't really need a border
add_style(`
img.has-children { border-color: #A7DF38; }
img.has-parent { border-color: #CCCC00; }
${config.post_border_style === 0 ? `
img.pending { border-color: #4B4BA3; }
` : config.post_border_style === 2 ? `
img.pending:is(.has-children,.has-parent) { outline: #4B4BA3 solid 2px; }
` : ''}
img.flagged { border-color: #F00; }
`);
/* sitefix for thumbnails not aligning properly anymore */
add_style('span.thumb { float: left; }');
/* readd missing thumbnail padding (so thumbnail icons don't jump around) */
add_style(`
span.thumb .preview:not(:hover,.has-parent,.has-children,.pending,.flagged,.deleted) {
padding: 2px;
}
`);
/* sitefix: darkmode has a small gap between the navbar <li>s and the <ul>s which partially breaks hovering */
/* This also fixes a visual issue in lightmode where the white borders cut into the gray navbar */
add_style(`
div#header ul#navbar li {
padding-bottom: 1px;
}
div#header ul#navbar li:hover > ul {
margin-top: 3px;
}
`);
/* Remove barely visible white border from subnav elements in lightmode */
add_style(`
div#header ul#navbar li {
border-color: transparent;
}
`);
/* sitefix for broken deletion page layout */
add_style(`
/* comparison box */
#content > .deleting-post {
clear: left; /* clearfix: ensure box starts below first thumbnail */
overflow: auto; /* fit its content */
}
/* balance margins */
#content > .deleting-post {
padding-top: 1em;
padding-bottom: 1em;
}
#content > .deleting-post > div {
margin-top: unset !important; /* important due to inline style */
margin-bottom: unset !important;
}
#content > .deleting-post > ul {
margin-bottom: unset;
}
/* align first thumbnail with comparison box */
#content > .thumb {
margin-left: calc(4em + 4px);
}
/* center warn tags / edit gear below thumbnails */
#content .deleting-post .thumb > * {
margin: auto;
}
`);
/* replace thumbnail centering logic (more robust, needed for thumbnail icons) */
add_style(`
span.thumb {
display: grid;
place-content: center;
}
img.preview {
position: static;
}
/* needed for thumbnail icons */
.thumb a {
position: relative;
}
`);
/* custom style for tag menu */
add_style(`
.tag_button {
display: inline-block;
border-style: solid;
border-width: 1px;
padding-left: 5px;
padding-right: 5px;
}
.tag_list, .tag_group {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
align-items: flex-start;
column-gap: 6px;
row-gap: 3px;
}
.tag_list {
padding-left: 3px;
padding-top: 3px;
padding-bottom: 1px;
}
.tag_group {
padding: 2px;
}
.tag_list table {
margin: 0;
}
.tag_group, .tag_list table {
margin-left: -3px;
margin-top: -2px;
}
a.tag_nonexistent {
color: #E00;
}
`);
}
/***************************/
/* 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 HOTKEY_ACTIONS = { // actions need unique names (for serialization)
postpage: {
reset_size: () => {
scale_image(SCALE_MODES.RESET, true);
scroll_to_image();
},
fit_size: () => {
scale_image(SCALE_MODES.FIT, true);
scroll_to_image();
},
fit_horizontal: () => {
scale_image(SCALE_MODES.HORIZONTAL, true);
scroll_to_image();
},
fit_vertical: () => {
scale_image(SCALE_MODES.VERTICAL, true);
scroll_to_image();
},
open_similar: () => {
open_in_tab(window.location.origin + '/post/similar?id=' + PostPage.post_id);
},
open_delete: () => {
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/' + PostPage.post_id);
}
},
add_translation: () => {
unsafeWindow.Note.create();
},
copy_translations: () => {
copy_translations();
},
paste_translations: () => {
paste_translations();
},
},
indexpage: {}
};
for (const mode of Object.keys(POST_MODE_DESCRIPTIONS)) {
HOTKEY_ACTIONS.indexpage[`${mode}_mode`] = () => select_mode(mode);
}
for (let i = 1; i <= 10; i++) {
HOTKEY_ACTIONS.indexpage[`select_tagscript_preset${i}`] = () => {
const dropdown = document.getElementById('tagscript_presets_dropdown');
dropdown.selectedIndex = i;
dropdown.dispatchEvent(new Event('change'));
};
}
function get_hotkey_action(action_name) {
for (const actions of Object.values(HOTKEY_ACTIONS)) {
const action = actions[action_name];
if (action) {
return action;
}
}
throw new Error(`Hotkey ${action_name} not found`);
}
class Hotkey {
constructor(action_name, modifiers) {
this.modifiers = modifiers ?? new Set();
this.action_name = action_name;
this.action = get_hotkey_action(action_name);
}
call(e) {
if (this.modifiers.has('ctrl') === e.ctrlKey
&& this.modifiers.has('alt') === e.altKey
&& this.modifiers.has('shift') === e.shiftKey) {
this.action();
}
}
}
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,
load_highres: false,
highres_limit: 4000000, // bytes
video_pause: false,
video_mute: true,
set_video_volume: false,
video_volume: 50,
video_controls: true,
show_speaker_icon: true,
show_animated_icon: true,
show_ratings_icon: false,
post_border_style: 0,
setparent_deletepotentialduplicate: false,
editform_deleteuselesstags: false,
hide_headerlogo: false,
tag_search_buttons: true,
or_tag_search_button: false,
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: '',
record_template:
`[
[
"Poor Tagging - neutral",
"Hello.\\n\\nPlease comply with our [[uploading rules]] when making new posts. [##]% of your posts do not have enough tags to get rid of their tagme status.\\n\\nIf you need help figuring out what tags to add, there's a great [[tag checklist]] that will give you lots of tags to add.\\n\\nUntil you correct this issue, I'll have to request that you not upload anything else. A failure to comply with our tagging standards can result in further staff action against your account.\\n\\nAs an extra bit of information, we require 13 general tags on color posts and 7 general tags on [[monochrome]] posts to avoid records such as this one.",
"neutral"
]
]`,
tagscript_presets:
`[
[
"Remove potential_duplicate",
"-potential_duplicate"
],
[
"futanari -> newhalf",
"newhalf -futanari -full-package_futanari"
]
]`,
tag_category_collapser: false,
tag_category_collapser_style: 0,
collapsed_tag_categories: new Set(),
add_filetype_stat: true,
move_stats_to_edit_form: false,
postpage_hotkeys: {
r: new Hotkey('reset_size'),
f: new Hotkey('fit_size'),
g: new Hotkey('fit_horizontal'),
h: new Hotkey('fit_vertical'),
s: new Hotkey('open_similar'),
d: new Hotkey('open_delete'),
t: new Hotkey('add_translation'),
c: new Hotkey('copy_translations'),
v: new Hotkey('paste_translations'),
},
indexpage_hotkeys: {
v: new Hotkey('set-parent_mode'),
c: new Hotkey('choose-parent_mode'),
s: new Hotkey('rating-s_mode'),
q: new Hotkey('rating-q_mode'),
e: new Hotkey('rating-e_mode'),
},
notes: [],
};
for (let i = 1; i <= 10; i++) {
const key = i < 10 ? String(i) : '0';
DEFAULT_CONFIG.indexpage_hotkeys[key] = new Hotkey(`select_tagscript_preset${i}`);
}
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 (to e.g. fix config elements returning strings when we need numbers)
const CONFIG_FIXER = {
scale_mode: Number,
tag_menu_layout: Number,
tag_category_collapser_style: Number,
highres_limit: Number,
post_border_style: Number,
};
function fix_config_entry(key, value) {
const fixer = CONFIG_FIXER[key];
return (fixer !== undefined ? fixer(value) : value);
}
// permanently save setting (and broadcast to other tabs)
function save_setting(key, value) {
value = fix_config_entry(key, value);
if (HAS_MONKEY_STORAGE) {
GM.setValue(key, JSON_stringify(value, json_replacer)).catch((reason) => {
show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason);
});
// use localStorage too if we don't have a change listener
if (GM.addValueChangeListener) return;
}
if (!HAS_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, json_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 (HAS_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 && HAS_LOCAL_STORAGE)
stored_value = localStorage.getItem(KEY_PREFIX + key);
if (stored_value !== undefined && stored_value !== null) {
try {
value = JSON.parse(stored_value, json_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, json_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 = General.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();
} else if (key === 'collapsed_tag_categories') {
for (const category of collapser_map.keys())
collapse_tag_category(category, config.collapsed_tag_categories.has(category), false);
}
}
function reset_setting(key) {
if (HAS_MONKEY_STORAGE) GM.deleteValue(key);
if (HAS_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);
}
}
// templates for the config dialog
const CONFIG_TABS_TEMPLATE = {
general: {
name: 'General',
categories: ['post', 'general'],
},
editing: {
name: 'Editing',
categories: ['editing'],
},
hotkeys: {
name: 'Hotkeys',
categories: ['postpage_hotkeys', 'indexpage_hotkeys'],
},
};
const CONFIG_CATEGORY_TEMPLATE = {
post: {
name: 'Image/Video',
entries: [
'scroll_to_image',
'scroll_to_image_center',
'scale_image',
'scale_only_downscale',
'scale_flash',
'scale_on_resize',
'scale_mode',
'load_highres',
'video_pause',
'video_mute',
'set_video_volume',
'video_controls',
],
},
general: {
name: 'General',
entries: [
'tag_search_buttons',
'or_tag_search_button',
'tag_category_collapser',
'tag_category_collapser_style',
'show_speaker_icon',
'show_animated_icon',
'show_ratings_icon',
'view_history_enabled',
'post_border_style',
'hide_headerlogo',
'sankaku_channel_dark_compatibility',
],
},
editing: {
name: 'Editing',
entries: [
'add_filetype_stat',
'move_stats_to_edit_form',
'setparent_deletepotentialduplicate',
'editform_deleteuselesstags',
'tag_menu',
COMMON_TAGS_KEY,
'tag_menu_layout',
'wiki_template',
'record_template',
'tagscript_presets',
],
},
postpage_hotkeys: {
name: 'Post Page Hotkeys',
entries: [],
},
indexpage_hotkeys: {
name: 'Index Page Hotkeys',
entries: [],
},
};
// expand hotkeys
for (const [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
for (const name of Object.keys(actions)) {
CONFIG_CATEGORY_TEMPLATE[`${page}_hotkeys`].entries.push(`${page}_hotkeys.${name}`);
}
}
const SETTINGS_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'}},
load_highres: {type: 'checkbox', desc: 'Load original image if smaller than ', title: 'Set to 0 bytes to always load'},
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: 'Enable + - tag search buttons*'},
or_tag_search_button: {type: 'checkbox', desc: 'Also add ~ tag search button*'},
show_speaker_icon: {type: 'checkbox', desc: `Show ${SPEAKER_SVG} icon on thumbnail if it has audio*`},
show_animated_icon: {type: 'checkbox', desc: `Show ${ANIMATED_SVG} icon on thumbnail if it is animated (${SPEAKER_SVG} overrides ${ANIMATED_SVG} )*`},
show_ratings_icon: {type: 'checkbox', desc: `Show ratings icon (${SAFE_SVG}, ${QUESTIONABLE_SVG}, ${EXPLICIT_SVG}) on post thumbnails*`},
view_history_enabled: {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
post_border_style: {type: 'select', desc: 'Post border style: ', options: {0: 'Prioritize blue \'unapproved\' border', 1: 'Priorizite yellow/green \'has parent / children\' borders', 2: 'Show both borders'}},
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)'},
tag_category_collapser: {type: 'checkbox', desc: 'Enable tag category collapsers on post pages*'},
tag_category_collapser_style: {type: 'select', desc: 'Tag category collapser style: ', options: {0: 'Compact', 1: 'With category name'}},
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'},
record_template: {type: 'text', desc: 'Record templates (JSON format):', title: 'A list of templates to be chosen from a dropdown menu on the record add page, each entry has a title followed by the actual content.'},
tagscript_presets: {type: 'text', desc: 'Tag script presets (JSON format):', title: 'A list of tag scripts to be chosen from a dropdown menu below "Mode" (put [] to disable).'},
add_filetype_stat: {type: 'checkbox', desc: 'Add file type to post "Details"*'},
move_stats_to_edit_form: {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form*'},
// TODO this is a mess, hotkeys should have a description
'postpage_hotkeys.reset_size': {type: 'hotkey', desc: 'Reset Image Size'},
'postpage_hotkeys.fit_size': {type: 'hotkey', desc: 'Fit Image'},
'postpage_hotkeys.fit_horizontal': {type: 'hotkey', desc: 'Fit Image (Horizontal)'},
'postpage_hotkeys.fit_vertical': {type: 'hotkey', desc: 'Fit Image (Vertical)'},
'postpage_hotkeys.open_similar': {type: 'hotkey', desc: 'Find Similar'},
'postpage_hotkeys.open_delete': {type: 'hotkey', desc: 'Delete Post'},
'postpage_hotkeys.add_translation': {type: 'hotkey', desc: 'Add Translation'},
'postpage_hotkeys.copy_translations': {type: 'hotkey', desc: 'Copy Translations'},
'postpage_hotkeys.paste_translations': {type: 'hotkey', desc: 'Paste Translations'},
};
for (const [mode, desc] of Object.entries(POST_MODE_DESCRIPTIONS)) {
SETTINGS_TEMPLATE[`indexpage_hotkeys.${mode}_mode`] = {type: 'hotkey', desc: desc + ' mode'};
}
for (let i = 1; i <= 10; i++) {
SETTINGS_TEMPLATE[`indexpage_hotkeys.select_tagscript_preset${i}`] = {type: 'hotkey', desc: `Select Tag Script Template #${i}`};
}
// whether a config element's value are accessed via '.value' (or otherwise '.checked')
function is_value_element(key) {
// hardcoded elements
if (key === 'video_volume') return true;
if (key === 'highres_limit') return true;
if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type
const type = SETTINGS_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) {
if (key.endsWith('_hotkeys')) {
update_hotkeys(key.slice(0, -'_hotkeys'.length));
return;
}
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 */
/********************/
const EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
class Tags { // thin wrapper around Set
tags;
constructor(tag_str) {
tag_str ??= '';
this.tags = new Set(tag_str.trim().split(/\s+/).filter(t => t.length !== 0));
}
static invert(tag) {
const minus = tag.startsWith('-');
return minus ? tag.substring(1) : `-${tag}`;
}
has(tag) {
return this.tags.has(tag);
}
add(tag) {
this.tags.add(tag);
this.tags.delete(Tags.invert(tag));
}
remove(tag) {
this.tags.delete(tag);
}
toggle(tag) {
if (this.has(tag)) {
this.remove(tag);
} else {
this.add(tag);
}
}
filter(pred) {
this.tags = new Set([...this.tags].filter(pred));
return this;
}
[Symbol.iterator]() {
return this.tags.values();
}
toArray() {
return [...this.tags];
}
toString() {
return [...this.tags].join(' ');
}
}
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=/; SameSite=Lax`;
}
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 modify_nodes(node_selector, node_modifier, root_selector) {
// MutationObserver (with childList: true, subtree: true) will observe every single node from the HTML as "added",
// but for script-inserted node trees, only the root node is counted.
// As a workaround, all subnodes of nodes matching root_selector will be checked manually
const observer = new MutationObserver(mutations => {
// for each added element
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// check for match
if (node.matches(node_selector) && node_modifier(node, observer)) { // are we done?
observer.disconnect();
return;
}
// check all subnodes of nodes matching root_selector
if (root_selector && node.matches(root_selector)) {
for (const subnode of node.getElementsByTagName('*')) {
if (subnode.matches(node_selector) && node_modifier(subnode, observer)) {
observer.disconnect();
return;
}
}
}
}
}
});
observer.observe(document, { childList: true, subtree: true });
// it's possible we are too late to observe the element's construction, so look for it afterwards immediately
for (const node of document.querySelectorAll(node_selector)) {
if (node_modifier(node, observer)) {
observer.disconnect();
break;
}
}
}
// call adjust_css() as early as possible
function modify_css() {
const try_adjust_css = () => {
if (document.body !== null) { // wait for body to guarantee head was loaded
observer.disconnect();
adjust_css();
}
};
const observer = new MutationObserver(try_adjust_css);
observer.observe(document, { childList: true, subtree: true });
try_adjust_css();
}
function get_scrollbar_width() {
const div = document.createElement('DIV');
div.style.overflow = 'scroll';
document.body.appendChild(div);
const scrollbar_width = div.offsetWidth - div.clientWidth;
div.remove();
return scrollbar_width;
}
// (almost) deepclone an object
function Object_clone(obj) {
if (typeof obj === 'object') {
// shallow clone containers
if (obj instanceof Array) return [...obj];
if (obj instanceof Set) return new Set(obj);
if (obj instanceof Map) return new Map(obj);
if (obj instanceof Hotkey) return new Hotkey(obj.action_name, new Set(obj.modifiers));
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 insert_node_after(node, ref_node) {
ref_node.parentNode.insertBefore(node, ref_node.nextSibling);
}
function get_resolution(obj) {
if (obj.src === 'about:blank') return null;
// natural size only for images, can be 0 when not yet loaded
// note: when src is changed, this can read the old size
if (obj.naturalWidth && obj.naturalHeight) {
return [obj.naturalWidth, obj.naturalHeight];
}
if (obj.videoWidth && obj.videoHeight) {
return [obj.videoWidth, obj.videoHeight];
}
return null;
}
function show_notice(logFunc, ...msg) {
unsafeWindow.notice?.(msg[0]);
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.removeProperty('background-color');
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);
}
function create_button() {
const el = document.createElement('BUTTON');
el.style.cursor = 'pointer';
return el;
}
function create_popup_menu() {
const popup = document.createElement('DIV');
popup.style.display = 'none';
popup.style.padding = '6px 12px 6px 12px';
popup.style.border = '1px solid ' + shifted_backgroundColor(32);
popup.style.backgroundColor = get_original_background_color();
// fixed, centered div
popup.style.top = '50%';
popup.style.left = '50%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.position = 'fixed';
popup.style.zIndex = '10002';
// scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins)
popup.style.minWidth = '30vw';
popup.style.maxWidth = '90vw';
popup.style.maxHeight = '90vh';
popup.style.overflow = 'auto';
return popup;
}
/**************************/
/* general site functions */
/**************************/
class Page {
static Index = Symbol('index/similar');
static Post = Symbol('post');
static Wiki = Symbol('wiki add/edit');
static WikiShow = Symbol('wiki show');
static Tag = Symbol('tag edit');
static Moderate = Symbol('moderate');
static Delete = Symbol('delete');
static User = Symbol('user');
static Record = Symbol('user record');
}
class General {
static page;
static thumbnail_cache = new Map(); // id -> array of thumbnail elements
}
function get_search_tags(location) {
location ??= window.location;
return new Tags(new URL(location.href).searchParams.get('tags'));
}
function get_username() { // won't work on every page
// read from the 'My Favorites' button (in one of the subnavs) in the navbar
for (const a of document.querySelectorAll('#navbar a')) {
if (typeof a.href !== 'string') continue;
for (const tag of new Tags(new URL(a.href).searchParams.get('tags'))) {
if (tag.startsWith('fav:')) {
return tag.substring('fav:'.length);
}
}
}
return null;
}
// is own uploads or favorites page
let personal_cache = null;
function is_personal_post_page() {
if (personal_cache !== null) return personal_cache;
const username = get_username();
if (username === null) {
personal_cache = false;
return false;
}
const tags = get_search_tags();
personal_cache = tags.has('fav:' + username) || tags.has('user:' + username);
return personal_cache;
}
function hide_headerlogo(hide) {
const headerlogo = document.getElementById('headerlogo');
if (headerlogo !== null) {
if (hide) headerlogo.style.display = 'none';
else headerlogo.style.removeProperty('display');
}
}
function add_config_dialog() {
const cfg_dialog = create_popup_menu();
cfg_dialog.style.zIndex = '10010';
cfg_dialog.id = 'cfg_dialog';
// generate the content of the config menu
let innerDivHTML = `<div style='font-weight: bold; margin-bottom: 6px;'>SankakuAddon ${VERSION}</div>`;
// + `<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid ${shifted_backgroundColor(32)};'>`;
// add tabs, TODO: they're ugly especially in dark mode
innerDivHTML += '<div id="cfg_tabs" style="display: flex; white-space: nowrap;">';
for (const [key, value] of Object.entries(CONFIG_TABS_TEMPLATE))
innerDivHTML += `<button id="cfg_tab_${key}" style="border-style: solid; padding: 0 2px 0 2px; margin: 0 2px 0 2px; background-color: transparent; border-bottom-width: 0; cursor: pointer">${value.name}</button>`;
innerDivHTML += '</div>';
// add bodies
for (const [body_key, body] of Object.entries(CONFIG_CATEGORY_TEMPLATE)) {
innerDivHTML += `<div id="cfg_body_${body_key}" style="background-color: rgba(128, 128, 128, 0.1); margin-bottom: 4px; padding: 0 4px 2px 4px;">`
+ `<h5>${body.name}</h5>`;
// add config elements for each body
for (const key of body.entries) {
const value = SETTINGS_TEMPLATE[key];
if (value === undefined) {
console.error(`couldn't find SETTINGS_TEMPLATE[${key}]`);
continue;
}
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>';
switch (value.type) {
case 'checkbox':
innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
innerDivHTML += generate_span();
// hardcoded elements:
innerDivHTML += (key === 'set_video_volume' ? `<input id="${KEY_PREFIX}video_volume" type="number" min="0" max="100" size="4">%` : '');
innerDivHTML += (key === 'load_highres' ? `<input id="${KEY_PREFIX}highres_limit" type="number" min="0" max="4000000" size="10"> bytes` : '');
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}" rows=8 style='width: 100%; box-sizing: border-box'></textarea>`;
break;
case 'hotkey':
innerDivHTML += `<input id='${KEY_PREFIX}${key}_ctrl' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
innerDivHTML += '<span style="vertical-align: middle;">ctrl</span>';
innerDivHTML += `<input id='${KEY_PREFIX}${key}_alt' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
innerDivHTML += '<span style="vertical-align: middle;">alt</span>';
innerDivHTML += `<input id='${KEY_PREFIX}${key}_shift' type='checkbox' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
innerDivHTML += '<span style="vertical-align: middle;">shift</span>';
innerDivHTML += `<input id='${KEY_PREFIX}${key}_key' type='text' maxLength='1' size='1' style='vertical-align: middle; margin: 3px 4px 3px 4px;'>`;
innerDivHTML += generate_span();
break;
default:
show_notice(console.error, '[addon error] CONFIG_TEMPLATE is defective!', value.type);
continue;
}
innerDivHTML += '</div>';
}
innerDivHTML += '</div>';
}
innerDivHTML += '<div style="padding: 2px">';
innerDivHTML += '<button id="config_close" style="cursor: pointer; margin-right: 6px">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 style="padding: 2px"> *requires a page reload.</div>';
cfg_dialog.innerHTML = innerDivHTML;
// adjust inline SVG icons
for (const svg of cfg_dialog.querySelectorAll('svg')) {
svg.style.width = '1rem';
svg.style.verticalAlign = 'middle';
}
document.body.appendChild(cfg_dialog);
// hide non-default categories
for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
if (other_tab_name !== 'general') {
for (const category of other_tab.categories)
document.getElementById(`cfg_body_${category}`).style.display = 'none';
}
}
// 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; };
// tab events
for (const [tab_name, tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
document.getElementById(`cfg_tab_${tab_name}`).onclick = () => {
// hide categories of other tabs
for (const [other_tab_name, other_tab] of Object.entries(CONFIG_TABS_TEMPLATE)) {
if (other_tab_name !== tab_name) {
for (const category of other_tab.categories) {
document.getElementById(`cfg_body_${category}`).style.display = 'none';
}
}
}
// show categories for given tab
for (const category of tab.categories) {
document.getElementById(`cfg_body_${category}`).style.display = 'block';
}
};
}
foreach_config_element((cfg_elem, key, get_value) => {
cfg_elem.addEventListener('change', () => {
update_setting(key, get_value());
save_setting(key, get_value());
});
});
// configure hotkey elements
for (const [page, actions] of Object.entries(HOTKEY_ACTIONS)) {
for (const name of Object.keys(actions)) {
for (const suffix of ['ctrl', 'alt', 'shift', 'key']) {
const cfg_elem = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${suffix}`);
cfg_elem.addEventListener('change', () => {
const hotkeys = get_hotkeys(page);
update_setting(`${page}_hotkeys`, hotkeys);
save_setting(`${page}_hotkeys`, hotkeys);
});
}
}
}
}
function get_hotkeys(page) { // get from config dialog
const hotkeys = {};
for (const [name, action] of Object.entries(HOTKEY_ACTIONS[page])) {
const modifiers = new Set();
for (const mod of ['ctrl', 'alt', 'shift']) {
if (document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${mod}`).checked) {
modifiers.add(mod);
}
}
const key = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_key`).value;
if (key.length !== 0) {
hotkeys[key] = new Hotkey(name, modifiers);
}
}
return hotkeys;
}
function update_hotkeys(page) {
// reset all hotkey config elements
for (const name of Object.keys(HOTKEY_ACTIONS[page])) {
for (const mod of ['ctrl', 'alt', 'shift']) {
const el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_${mod}`);
if (el !== null)
el.checked = false;
}
const el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${name}_key`);
if (el !== null)
el.value = '';
}
// update hotkey config elements
for (const [k, v] of Object.entries(config[`${page}_hotkeys`])) {
const key_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${v.action_name}_key`);
if (key_el !== null)
key_el.value = k;
for (const mod of ['ctrl', 'alt', 'shift']) {
const mod_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${v.action_name}_${mod}`);
if (mod_el !== null)
mod_el.checked = v.modifiers.has(mod);
}
}
}
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.style.paddingRight = '10px';
li.appendChild(a);
navbar.appendChild(li);
}
function add_tag_search_buttons() {
for (const item of document.querySelectorAll('#tag-sidebar li')) {
const taglink = item.querySelector('a');
if (taglink === null) continue;
const tagname = get_search_tags(taglink).toString();
const get_click_listener = (tag) => {
return () => {
const search_field = document.getElementById('tags');
const search_tags = new Tags(search_field.value);
search_tags.toggle(tag);
search_field.value = search_tags.toString() + ' ';
search_field.setSelectionRange(search_field.value.length, search_field.value.length);
search_field.focus({ preventScroll: true });
return false;
};
};
const add_search_button = (tag_prefix) => {
const a = document.createElement('A');
a.href = '#';
a.innerText = tag_prefix;
a.onclick = get_click_listener((tag_prefix === '+' ? '' : tag_prefix) + tagname);
item.insertBefore(a, taglink);
item.insertBefore(document.createTextNode(' '), taglink);
};
add_search_button('+');
add_search_button('-');
if (config.or_tag_search_button)
add_search_button('~');
}
}
const collapser_map = new Map(); // category -> [collapser, tags]
const collapser_color_map = new Map(); // category -> font color
function collapse_tag_category(category, collapse, save = true) {
const [collapser, tags] = collapser_map.get(category);
const a = collapser.children[0];
const middle_div = a.children[1];
// collapse/expand category
if (collapse) {
for (const tag of tags)
tag.style.display = 'none';
} else {
for (const tag of tags)
tag.style.removeProperty('display');
}
// change collapser visuals
if (config.tag_category_collapser_style === 0) { // compact style
if (collapse) {
middle_div.style.height = '0';
middle_div.style.borderTopWidth = '3px';
middle_div.style.borderBottomWidth = '3px';
middle_div.style.marginTop = '3px';
middle_div.style.marginBottom = '3px';
} else {
middle_div.style.height = '4px';
middle_div.style.borderTopWidth = '2px';
middle_div.style.borderBottomWidth = '2px';
middle_div.style.marginTop = '2px';
middle_div.style.marginBottom = '2px';
}
} else if (config.tag_category_collapser_style === 1) { // with category name
if (collapse) {
// swap border and font color
middle_div.style.color = get_original_background_color();
middle_div.style.backgroundColor = collapser_color_map.get(category);
} else {
middle_div.style.removeProperty('color');
middle_div.style.removeProperty('background-color');
}
}
// retain collapse state
if (collapse) config.collapsed_tag_categories.add(category);
else config.collapsed_tag_categories.delete(category);
if (save) save_setting('collapsed_tag_categories', config.collapsed_tag_categories);
}
let drag_collapse = false;
let drag_collapse_categories;
function drag_collapse_down(e) {
e.preventDefault();
drag_collapse = true;
const category = e.currentTarget.className;
drag_collapse_categories = !config.collapsed_tag_categories.has(category);
collapse_tag_category(category, drag_collapse_categories);
}
function drag_collapse_move(e) {
if (!drag_collapse) return;
const category = e.currentTarget.className;
if (drag_collapse_categories !== config.collapsed_tag_categories.has(category))
collapse_tag_category(category, drag_collapse_categories);
}
function drag_collapse_up() {
drag_collapse = false;
}
function add_tag_category_collapser() {
const tagsidebar = document.getElementById('tag-sidebar');
if (tagsidebar === null) return;
const items = tagsidebar.getElementsByTagName('LI');
window.addEventListener('mouseup', drag_collapse_up);
const setup_collapser = (collapser, category, tags) => {
collapser_map.set(category, [collapser, tags]);
collapser.addEventListener('mousedown', drag_collapse_down);
collapser.addEventListener('mousemove', drag_collapse_move);
for (const tag of tags)
tag.addEventListener('mousemove', drag_collapse_move);
};
let curr_category = null;
let curr_category_tags = [];
let prev_category_collapser = null;
const categories = [];
for (const item of items) {
if (item.className === curr_category) {
curr_category_tags.push(item);
} else { // reached new category
if (prev_category_collapser !== null)
setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);
// remember category color, workaround for color changing on hover
for (const a of item.getElementsByTagName('A')) {
collapser_color_map.set(item.className, window.getComputedStyle(a).getPropertyValue('color'));
break;
}
curr_category = item.className;
curr_category_tags = []; // item will be pushed in the next iteration, see warning below
categories.push(curr_category);
const a = document.createElement('A');
a.href = '#';
a.addEventListener('click', (e) => e.preventDefault());
// collapser visuals
a.style.display = 'flex';
a.style.justifyContent = 'center';
a.style.alignItems = 'center';
if (config.tag_category_collapser_style === 0) { // compact style
a.innerHTML =
'<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
'<div style="width:5%; height: 4px; border-width: 2px; margin: 2px 2px 2px 2px; border-style: solid"></div>' +
'<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
} else { // with category name
const category_name = curr_category.substring('tag-type-'.length);
a.style.paddingLeft = '2.5%';
a.style.paddingRight = '2.5%';
a.innerHTML =
'<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>' +
`<div style="border-width: 2px; margin: 2px 2px 2px 2px; padding-left: 4px; padding-right: 4px; border-style: solid">${category_name}</div>` +
'<div style="width:50%; height: 0; border-top-width: 1px; border-bottom-width: 1px; border-style: solid;"></div>';
}
const collapser = document.createElement('LI');
collapser.className = item.className;
collapser.appendChild(a);
prev_category_collapser = collapser;
// warning: modifies iterating list, current item will be processed twice
item.insertAdjacentElement('beforebegin', collapser);
}
}
// setup last collapser
if (prev_category_collapser !== null)
setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]);
for (const category of categories)
if (config.collapsed_tag_categories.has(category))
collapse_tag_category(category, true, false);
}
function get_thumbnail_post_id(thumb) {
const pid = thumb.id;
if (pid === '') return null; // first thumbnail on similar page doesn't have post id
if (!pid.startsWith('p')) {
console.error('[addon error] invalid thumbnail id');
return null;
}
const id = Number(pid.substring(1));
if (Number.isNaN(id)) {
console.error('[addon error] thumbnail id isn\'t a number', id);
return null;
}
return id;
}
const post_cache = new Map(); // returns object with tags array and rating
function get_post(post_id) {
// might not be loaded yet or exist at all (e.g on deletion page)
let post = unsafeWindow.Post?.posts?.get(post_id);
if (post !== undefined) return post;
post = post_cache.get(post_id);
if (post !== undefined) return post;
return null;
}
function get_post_from_thumb(thumb) {
const img = thumb.querySelector('.preview');
if (img === null) {
show_notice(console.error, '[addon error] thumbnail has no preview image');
return null;
}
const post_id = get_thumbnail_post_id(thumb);
let post = get_post(post_id);
if (post !== null) return post;
// note: thumbnail title tags are slightly different, e.g. Rating:Safe instead of rating:s
let tags = new Tags(img.title).toArray();
// find rating
let rating = null;
for (const tag of tags) {
if (tag.startsWith('Rating:')) {
rating = tag.substring('Rating:'.length)[0].toLowerCase();
break;
}
}
// remove "match tags"
tags = tags.filter((tag) => {
return !(tag.startsWith('Rating:') || tag.startsWith('Score:') || tag.startsWith('Size:') || tag.startsWith('User:'));
});
post = {
tags,
rating,
};
post_cache.set(post_id, post);
return post;
}
function modify_thumbnail(preview) {
// MutationObserver is waiting for .preview so we can guarantee all the necessary data is there
const thumb_a = preview.parentElement;
const thumb = thumb_a?.parentElement;
// still make extra sure stuff's in place
if (thumb_a?.tagName !== 'A') {
show_notice(console.error, '[addon error] thumbnail has no anchor tag?!');
return;
} else if (!thumb?.matches('span.thumb')) {
show_notice(console.error, '[addon error] thumbnail has no thumb span?!');
return;
}
const post_id = get_thumbnail_post_id(thumb);
if (post_id === null) return;
// use and update thumbnail_cache
const thumbs = General.thumbnail_cache.get(post_id) ?? [];
const is_new = !thumbs.includes(thumb);
if (is_new) thumbs.push(thumb);
General.thumbnail_cache.set(post_id, thumbs);
if (is_new) {
override_thumbnail_click_event(thumb_a);
add_thumbnail_icons(thumb);
if (!is_personal_post_page() && General.page !== Page.Delete)
fadeout_viewed_post(thumb, post_id);
}
}
function add_thumbnail_icons(thumb) {
if (!(config.show_speaker_icon || config.show_animated_icon || config.show_ratings_icon)) return;
const post = get_post_from_thumb(thumb);
if (post == null) return;
const icons = document.createElement('SPAN');
icons.style.whiteSpace = 'nowrap';
if (config.show_ratings_icon) {
icons.insertAdjacentHTML('beforeend', RATING_SVG[post.rating]);
}
if (config.show_speaker_icon && (post.tags.includes('has_audio'))) {
icons.insertAdjacentHTML('beforeend', SPEAKER_SVG);
} else if (config.show_animated_icon && (post.tags.includes('animated') || post.tags.includes('video') || post.tags.includes('slideshow'))) {
icons.insertAdjacentHTML('beforeend', ANIMATED_SVG);
}
icons.className = 'thumbnail_icons';
icons.style.position = 'absolute';
icons.style.top = '2px'; // account for border
icons.style.right = '2px';
icons.style.transform = `translateX(${SVG_SIZE / 2}px) translateY(-${SVG_SIZE / 2}px)`;
thumb.querySelector('a').appendChild(icons);
}
function fadeout_post(thumb) {
if (!config.view_history_enabled) return;
const a = thumb.querySelector('a');
const img = thumb.querySelector('img');
// move box shadow from image to link, so opacity doesn't affect it
a.style.display = 'inline-block';
a.style.boxShadow = window.getComputedStyle(img).getPropertyValue('box-shadow');
img.style.removeProperty('box-shadow');
img.style.opacity = '20%';
for (const thumbnail_icons of thumb.getElementsByClassName('thumbnail_icons'))
thumbnail_icons.style.opacity = '20%';
}
function fadeout_viewed_post(thumb, id) {
if (config[HISTORY_KEY].has(id))
fadeout_post(thumb);
}
function configure_video(node) {
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;
}
function useful_beta_link(pathname) { // idea and some code donated by Octopus Hugger
const params = new URL(window.location.href).searchParams;
const betaLink = new URL('https://beta.sankakucomplex.com/');
// post index/page, simply replace chan with beta and keep search parameters
if (pathname === '/' || pathname.startsWith('/post/show')) {
betaLink.pathname = pathname;
betaLink.search = window.location.search;
// user page, /user/show/<id> -> /user/show?id=<id>
} else if (pathname.startsWith('/user/show')) {
const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
const user_id = temp.substring(temp.lastIndexOf('/') + 1);
betaLink.pathname = '/user/show';
betaLink.searchParams.set('id', user_id);
// wiki, /wiki/show?title=<entry> -> /tag/en?tagName=<entry>
} else if (pathname.startsWith('/wiki/show')) {
betaLink.pathname = '/tag/en';
betaLink.searchParams.set('tagName', params.get('title'));
// pool, /pool/show/<id> -> /books/<id>
} else if (pathname.startsWith('/pool/show/')) {
betaLink.pathname = '/books/' + pathname.substring('/pool/show/'.length);
}
// update beta link
for (const a of document.querySelectorAll('#navbar a[href="https://beta.sankakucomplex.com/"]')) {
a.href = betaLink.href;
}
}
/***********************************************/
/* main page / visually similar page functions */
/***********************************************/
class IndexPage {
static has_tag_scripts;
static init() {
IndexPage.has_tag_scripts = (document.querySelector('#mode > option[value=apply-tag-script]') !== null);
}
}
function select_mode(mode) {
const mode_dropdown = document.getElementById('mode');
const old_mode = mode_dropdown.value;
mode_dropdown.value = mode;
if (!mode_dropdown.value) mode_dropdown.value = old_mode; // couldn't set mode (option doesn't exist)
PostModeMenu_change_override();
}
function add_mode_options() {
const mode_dropdown = document.getElementById('mode');
if (mode_dropdown === null) return; // not logged in
const add_mode_option = (text, value) => {
const option = document.createElement('option');
option.text = text;
option.value = value;
mode_dropdown.add(option);
};
if (IndexPage.has_tag_scripts) {
add_mode_option('Choose Parent', 'choose-parent');
add_mode_option('Set Parent', 'set-parent');
}
add_mode_option('Edit Tags', 'edit-tags');
add_mode_option('Find Similar', 'find-similar');
if (IndexPage.has_tag_scripts) add_mode_option('Delete Post', 'delete'); // rough estimate of user permissions
override_mode_change_event(mode_dropdown);
PostModeMenu_init_workaround(); // guarantee that 'mode' correctly changes to new modes when loading page
}
function add_post_edit_dialog() {
const dialog = create_popup_menu();
dialog.id = 'post_edit_dialog';
dialog.style.borderWidth = '4px';
// edit box, modified from the post page
dialog.innerHTML =
`<div id="SA-edit" style="display: flex; align-items: center;">
<div style="width: 300px; height: 300px; display: flex; align-items: center; justify-content: center; margin-right: 12px">
<img id="SA-edit-image" src="${EMPTY_IMAGE}">
</div>
<div>
<h5><a id="post-link" href="" target="_blank"></a></h5>
<form id="SA-edit-form" method="post" style="width: fit-content">
<input id="SA-post_old_tags" name="post[old_tags]" type="hidden" value="">
<table class="form">
<tfoot>
<tr>
<td colspan="2" style="white-space: nowrap"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
</td>
</tr>
</tfoot>
<tbody>
<tr>
<th style="width: 10%">
<label class="block" for="SA-post_rating_questionable">Rating</label>
</th>
<td style="width: 90%">
<input id="SA-post_rating_explicit" name="post[rating]" tabindex="1" type="radio" value="explicit">
<label for="SA-post_rating_explicit">R18+</label>
<input id="SA-post_rating_questionable" name="post[rating]" tabindex="2" type="radio" value="questionable">
<label for="SA-post_rating_questionable">R15+</label>
<input checked="" id="SA-post_rating_safe" name="post[rating]" tabindex="3" type="radio" value="safe">
<label for="SA-post_rating_safe">G</label>
</td>
</tr>
<tr>
<th>
<label class="block" for="SA-post_tags">Tags</label>
</th>
<td>
<textarea cols="83" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off"></textarea>
</td>
</tr>
</tbody>
</table>
</form>
</div>
</div>`;
document.body.appendChild(dialog);
add_tag_buttons('SA-edit-form');
// hide when clicking outside
document.addEventListener('click', (e) => {
if (is_post_edit_dialog_visible()) {
if (e.target.closest('#post_edit_dialog, #autosuggest') !== null)
return; // clicked inside
show_post_edit_dialog(false);
e.preventDefault();
}
}, true);
enable_auto_suggest();
}
async function enable_auto_suggest() {
while (unsafeWindow.AutoSuggest === undefined)
await sleep(250);
unsafeWindow.AutoSuggest.add('#SA-post_tags');
}
function show_post_edit_dialog(bool) {
const dialog = document.getElementById('post_edit_dialog');
if (dialog !== null) dialog.style.display = (bool ? 'block' : 'none');
}
function is_post_edit_dialog_visible() {
const dialog = document.getElementById('post_edit_dialog');
return dialog?.style.display === 'block';
}
function open_post_edit_dialog(thumb) {
const dialog = document.getElementById('post_edit_dialog');
if (dialog === null) {
show_notice(console.error, '[addon error] tag edit popup is missing?!');
return;
}
const post_id = get_thumbnail_post_id(thumb);
if (post_id === null) return;
const a = thumb.querySelector('a');
if (a === null) return;
const edit_image = document.getElementById('SA-edit-image');
const post_old_tags = document.getElementById('SA-post_old_tags');
const post_tags = document.getElementById('SA-post_tags');
const form = document.getElementById('SA-edit-form');
const post_link = document.getElementById('post-link');
// set thumbnail image and post link
edit_image.src = EMPTY_IMAGE;
edit_image.src = thumb.querySelector('.preview').src;
post_link.href = a.href;
post_link.innerText = 'Post ' + post_id;
const full_rating = (rating) => ({ s: 'safe', q: 'questionable', e: 'explicit' }[rating]);
// get tags and rating
const post = get_post_from_thumb(thumb);
const tags = post.tags.join(' ');
const rating = full_rating(post.rating);
// set edit box contents
post_old_tags.value = tags;
post_tags.value = tags;
if (rating !== null) {
document.getElementById('SA-post_rating_' + rating).checked = true;
}
update_tag_elements();
form.onsubmit = async (event) => {
event.preventDefault(); // block reloading page
delete_useless_tags_tag();
const submitted_tags = new Tags(post_tags.value).toArray();
show_notice(console.log, '[addon] saving...');
// manually submit data
try {
const response = await fetch(new URL(`/post/update/${post_id}`, document.location), {
method: 'POST',
body: new FormData(form),
redirect: 'manual', // this will otherwise err because of a https -> http redirect
});
// we assume success on redirect
if (response.type === 'opaqueredirect' || response.ok) {
// update local tags
post.tags = submitted_tags;
deletion_sanity_checks();
show_notice(console.log, '[addon] saved tags!');
} else {
show_notice(console.error, '[addon error] couldn\'t save tags!');
}
} catch (error) {
show_notice(console.error, '[addon error] network error while saving tags!', error);
}
};
show_post_edit_dialog(true);
}
function add_postmode_hotkeys() {
document.addEventListener('keydown', (e) => {
const mode_dropdown = document.getElementById('mode');
const script_presets = document.getElementById('tagscript_presets_dropdown');
if (mode_dropdown === null) return;
if (e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.target === mode_dropdown || (script_presets !== null && e.target === script_presets)) {
e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts'
} else if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) {
return;
}
config.indexpage_hotkeys[e.key.toLowerCase()]?.call(e);
}, true);
}
function PostModeMenu_init_workaround() {
// issue: new post modes can reset on page load if they were added too late
// reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then
// calls PostModeMenu.change, which sets the cookie to mode_dropdown.value.
// so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset
// solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added
const mode = get_cookie('addon_mode');
if (mode !== '') {
set_cookie('mode', mode, 7);
document.getElementById('mode').value = mode;
}
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;
}
// setting mode failed, possible when changing to account with lower permissions
if (!mode_dropdown.value) {
show_notice(console.error, '[addon error] invalid mode, resetting to \'view\'');
mode_dropdown.value = 'view';
set_cookie('mode', 'view', 7);
}
// try to guarantee sitescript has loaded TODO: future note: potential infinite loop
while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined)
await sleep(100);
unsafeWindow.PostModeMenu.change();
const s = mode_dropdown.value;
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: luminance 40
document.body.style.backgroundColor = (darkmode ? '#500050' : '#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!');
select_mode('choose-parent');
} else {
document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF');
}
} else if (s === 'edit-tags') {
document.body.style.backgroundColor = (darkmode ? '#006400' : '#3A3');
} else if (s === 'find-similar') {
document.body.style.removeProperty('background-color');
} else if (s === 'delete') {
document.body.style.backgroundColor = 'rgba(147, 0, 0, 0.7)';
}
}
function PostModeMenu_click_override(event) {
const thumb_a = event.currentTarget;
const thumb = thumb_a.parentElement;
const post_id = get_thumbnail_post_id(thumb);
if (unsafeWindow.PostModeMenu.click(post_id))
return true; // view mode, let it click
const mode_dropdown = document.getElementById('mode');
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' : ''));
} else if (s === 'edit-tags') {
open_post_edit_dialog(thumb);
} else if (s === 'find-similar') {
open_in_tab(window.location.origin + '/post/similar?id=' + post_id);
} else if (s === 'delete') {
open_in_tab(window.location.origin + '/post/delete/' + post_id);
}
return false;
}
function override_mode_change_event(mode_dropdown) {
mode_dropdown.removeAttribute('onchange');
mode_dropdown.onchange = PostModeMenu_change_override;
}
function override_thumbnail_click_event(thumb_a) {
thumb_a.removeAttribute('onclick');
thumb_a.onclick = PostModeMenu_click_override;
}
/***********************/
/* post page functions */
/***********************/
// TODO expand this
class PostPage {
static post_id = null;
static parent_id = null;
static init(pathname) {
PostPage.post_id = document.getElementById('hidden_post_id')?.innerText;
if (PostPage.post_id == null) {
const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
PostPage.post_id = temp.substring(temp.lastIndexOf('/') + 1);
}
PostPage.parent_id = document.getElementById('post_parent_id')?.value;
}
}
// original post/parent ids
let image_data = null;
let resize_timer;
let tag_update_timer;
let tags_changed = false;
// set by find_actions_list():
let found_delete_action = false;
let done_scrolling = false;
function is_done_scrolling() {
return !config.scroll_to_image || done_scrolling;
}
class TagMenuScaler {
static #mouse_moved = false;
static mousedown(e) {
e.preventDefault();
TagMenuScaler.#mouse_moved = false;
window.addEventListener('mousemove', TagMenuScaler.mousemove);
window.addEventListener('mouseup', TagMenuScaler.mouseup);
}
static mousemove(e) {
e.preventDefault();
TagMenuScaler.#mouse_moved = true;
TagMenuScaler.set_scale(e, false);
}
static mouseup(e) {
e.preventDefault();
if (TagMenuScaler.#mouse_moved) TagMenuScaler.set_scale(e, true);
window.removeEventListener('mousemove', TagMenuScaler.mousemove);
window.removeEventListener('mouseup', TagMenuScaler.mouseup);
}
static set_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', TagMenuScaler.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();
delete_useless_tags_tag();
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.removeProperty('display');
}
const create_tag_button = function(tag, skip_common_tags_update = false) {
const a = document.createElement('A');
a.href = '#';
a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons
a.classList.add('tag_button');
if (!get_post_tags().has(tag)) a.classList.add('tag_nonexistent');
a.onclick = function(e) {
if (e.ctrlKey) {
open_in_tab(window.location.origin + '/wiki/show/?title=' + tag);
return false;
}
if (get_post_tags().has(tag)) {
remove_post_tag(tag, skip_common_tags_update);
a.classList.add('tag_nonexistent');
} else {
add_post_tag(tag, skip_common_tags_update);
a.classList.remove('tag_nonexistent');
}
return false;
};
a.innerText = tag;
return a;
};
const create_tag_list = function() {
const div = document.createElement('DIV');
div.className = 'tag_list';
return div;
};
// generate tag button list for current tags
const current_tags_flex = create_tag_list();
current_tags_flex.style.marginBottom = '3px';
for (const current_tag of get_post_tags()) {
current_tags_flex.appendChild(create_tag_button(current_tag, false));
}
// 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(new Tags(list_tag[j][0]).toArray());
}
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)><table><tr><td><div (button)>
// TODO maybe replace with grid
const table = document.createElement('TABLE');
table.style.display = 'inline-block';
group_style(table);
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(create_tag_button(tags_table[row][col], true));
tr.appendChild(td);
}
table.appendChild(tr);
}
list_flex.appendChild(table);
} 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 (flexbox)><div (button)>
const group_div = document.createElement('DIV');
group_div.className = 'tag_group';
group_style(group_div);
for (const tag of tags)
group_div.appendChild(create_tag_button(tag, true));
list_flex.appendChild(group_div);
} else /* if (tags_type === tag_types.LIST) */ {
// <div (flexbox)><div (button)>
const tags = new Tags(list_tag);
for (const tag of tags) {
list_flex.appendChild(create_tag_button(tag, true));
}
}
}
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 = get_post_tags_el();
if (post_tags === null) return; // not logged in
const delayed_update = () => {
tags_changed = true;
clearTimeout(tag_update_timer);
tag_update_timer = setTimeout(update_tag_elements, 400);
};
post_tags.addEventListener('change', delayed_update);
post_tags.addEventListener('input', delayed_update);
}
function add_tags_submit_listener() {
document.getElementById('edit-form')?.addEventListener('submit', () => {
delete_useless_tags_tag();
});
}
function find_actions_list() {
let actions_ul;
for (const a of document.querySelectorAll('.sidebar > div > ul:not(#tag-sidebar) > li > a')) {
try {
const pathname = new URL(a.href).pathname;
if (pathname.startsWith('/post/similar')) {
actions_ul = a.parentElement.parentElement;
} else if (pathname.startsWith('/post/delete/')) {
found_delete_action = true;
}
} catch (ignored) {}
}
return actions_ul;
}
function copy_translations() {
config.notes = [];
for (const note of unsafeWindow.Note.all) {
config.notes.push({
text: note.old.raw_body,
fullsize: note.fullsize,
});
}
save_setting('notes', config.notes);
show_notice(console.log, '[addon] copied notes');
}
function paste_translations() {
show_notice(console.log, '[addon] pasted notes');
// TODO check https://chan.sankakucomplex.com/wiki/show?title=about%3Anote_formatting
// TODO save all notes
for (const note of config.notes) {
unsafeWindow.Note.create();
const new_note = unsafeWindow.Note.all.at(-1);
const box = new_note.elements.box;
const image = new_note.elements.image;
const ratio = new_note.ratio();
// set text
new_note.elements.body.innerText = note.text;
new_note.old.raw_body = note.text;
// set size
const width = note.fullsize.width * ratio;
const height = note.fullsize.height * ratio;
box.style.width = width + 'px';
box.style.height = height + 'px';
// set position
const clipX = (x) => Math.max(5, Math.min(x, image.clientWidth - box.clientWidth - 5));
const clipY = (y) => Math.max(5, Math.min(y, image.clientHeight - box.clientHeight - 5));
const top = clipY(note.fullsize.top * ratio);
const left = clipX(note.fullsize.left * ratio);
box.style.top = top + 'px';
box.style.left = left + 'px';
}
}
function add_addon_actions(actions_ul) {
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(); }, 'Fit image', 'scale-image-fit');
add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(); }, 'Fit image (Horizontal)', 'scale-image-hor');
add_action(() => { scale_image(SCALE_MODES.VERTICAL, true); scroll_to_image(); }, 'Fit image (Vertical)', 'scale-image-ver');
add_action(() => { scale_image(SCALE_MODES.RESET, true); scroll_to_image(); }, 'Reset image size', 'reset-image');
if (PostPage.parent_id === null) return; // not logged in
add_action(() => { flag_duplicate(PostPage.post_id, ''); }, 'Flag duplicate', 'flag-duplicate');
add_action(() => { flag_duplicate(PostPage.post_id, ', visually identical'); }, 'Flag duplicate (identical)', 'flag-duplicate-identical');
add_action(() => { flag_duplicate(PostPage.post_id, ' with worse quality'); }, 'Flag duplicate (quality)', 'flag-duplicate-quality');
add_action(() => { flag_duplicate(PostPage.post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution');
add_action(copy_translations, 'Copy Notes', 'copy-notes');
add_action(paste_translations, 'Paste Notes', 'paste-notes');
}
function add_filetype_stat() {
try {
const img_link = document.getElementById('highres');
if (img_link === null) return; // flash?
const s = new URL(img_link.href).pathname.split('.');
const ext = s.at(-1);
const filetype = document.createTextNode(`File type: ${ext}`);
const li = document.createElement('LI');
li.appendChild(filetype);
insert_node_after(li, img_link.parentNode);
} catch (error) {
console.error('[addon error] add_filetype_stat failed with', error);
}
}
function move_stats_to_edit_form() {
try {
// form display: flex
// stats insertafter table
// stats white-space: nowrap
// #edit-form width: max-content
// margin-left: 8px;
const stats = document.getElementById('stats');
const edit_form = document.getElementById('edit-form');
if (edit_form === null) return;
const table = edit_form.querySelector('table');
edit_form.style.display = 'flex'; // add to the right
insert_node_after(stats, table);
stats.style.whiteSpace = 'nowrap';
edit_form.style.width = 'max-content';
stats.style.marginLeft = '8px';
} catch (error) {
console.error('[addon error] move_stats_to_edit_form failed with', error);
}
}
function add_tag_buttons(form_id) {
const edit_form = document.getElementById(form_id);
if (edit_form === null) return; // not logged in
const tag_button_place = edit_form.querySelector('table > tfoot > tr > td');
tag_button_place.style.whiteSpace = 'nowrap';
// save button min-width is 25%, which makes the <td> not fit its content
tag_button_place.children[0].style.minWidth = '13.5em';
const parent_el = document.getElementById('post_parent_id');
{
const el = create_button();
el.id = 'clear_parent_id_button';
el.style.margin = '0 3px 0 6px';
el.innerText = 'Clear';
el.onclick = () => { parent_el.value = ''; return false; };
parent_el?.parentNode?.appendChild(el);
}
{
const el = create_button();
el.id = 'reset_parent_id_button';
el.style.margin = '0 3px';
el.innerText = 'Reset';
el.onclick = () => { reset_parent_id(); return false; };
parent_el?.parentNode?.appendChild(el);
}
{
const el = create_button();
el.id = 'tag_reset_button';
el.style.margin = '0 3px 0 6px';
el.innerText = 'Reset';
el.onclick = () => { reset_tags(); return false; };
tag_button_place.appendChild(el);
}
const append_tag_button = (id, tag) => {
const el = create_button();
el.id = id;
el.className = 'SA-tag-button';
el.dataset['tag'] = tag;
el.style.margin = '0 3px';
el.onclick = () => { toggle_post_tag(tag); return false; };
tag_button_place.appendChild(el);
};
append_tag_button('tag_dup_button', 'duplicate');
append_tag_button('tag_var_button', 'legitimate_variation');
append_tag_button('tag_rev_button', 'revision');
append_tag_button('tag_has_rev_button', 'has_revised_version');
append_tag_button('tag_pot_button', 'potential_duplicate');
}
function update_tag_buttons() {
const taglist = get_post_tags_el();
if (taglist === null)
return;
const tags = get_post_tags();
for (const button of document.querySelectorAll('.SA-tag-button')) {
const tag = button.dataset['tag'];
if (tag === 'potential_duplicate') {
if (tags.has(tag)) {
button.disabled = false;
button.style.cursor = 'pointer';
} else {
button.disabled = true;
button.style.removeProperty('cursor');
}
}
button.innerText = (tags?.has(tag) ? '-' : '+') + tag;
}
}
function reset_parent_id() {
document.getElementById('post_parent_id').value = PostPage.parent_id;
}
function get_post_old_tags_el() {
return document.querySelector('#post_old_tags, #SA-post_old_tags');
}
function get_post_tags_el() {
return document.querySelector('#post_tags, #SA-post_tags');
}
function get_post_old_tags() {
return new Tags(get_post_old_tags_el()?.value);
}
function get_post_tags() {
return new Tags(get_post_tags_el()?.value);
}
function toggle_post_tag(tag, skip_common_tags_update = false) {
if (get_post_tags().has(tag)) {
remove_post_tag(tag, skip_common_tags_update);
} else {
add_post_tag(tag, skip_common_tags_update);
}
}
function add_post_tag(tag, skip_common_tags_update = false) {
const tags_el = get_post_tags_el();
const tags = get_post_tags();
if ((tag === 'duplicate' && tags.has('legitimate_variation')) || (tag === 'legitimate_variation' && tags.has('duplicate'))) {
show_notice(console.warn, '[addon] cannot tag as duplicate and legitimate_variation at the same time.');
return;
}
tags.add(tag);
tags_el.value = tags.toString() + ' ';
tags_changed = true;
update_tag_elements(skip_common_tags_update);
}
function remove_post_tag(tag, skip_common_tags_update = false) {
const tags = get_post_tags();
tags.remove(tag);
get_post_tags_el().value = tags.toString() + ' ';
tags_changed = true;
update_tag_elements(skip_common_tags_update);
}
function delete_useless_tags_tag() {
if (tags_changed && config.editform_deleteuselesstags) remove_post_tag('useless_tags');
}
function reset_tags() {
get_post_tags_el().value = get_post_old_tags_el().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(post_id, reason_suffix) {
if (PostPage.parent_id === null) {
show_notice(console.warn, '[addon] parent id not found, not logged in?');
return;
}
const current_parent_id = document.getElementById('post_parent_id')?.value;
if (current_parent_id !== PostPage.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_post_tags();
const old_tags = get_post_old_tags();
if (tags.has('duplicate') && !old_tags.has('duplicate')) {
show_notice(console.warn, '[addon] duplicate tag set but not saved!');
return;
}
if (!old_tags.has('duplicate')) {
show_notice(console.warn, '[addon] not tagged as duplicate!');
return;
}
if (old_tags.has('legitimate_variation') || old_tags.has('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 ${PostPage.parent_id}${reason_suffix}`);
if (reason === null)
return;
(async function() {
try {
const response = await fetch(new URL('/post/flag.json', document.location), {
method: 'POST',
body: new URLSearchParams({ id: post_id, reason }),
});
if (!response.ok) {
show_notice(console.error, `[addon error] non-OK status code ${response.status}`);
return;
}
const data = await response.json();
if (data.success) show_notice(console.log, 'Post was resent to moderation queue');
else show_notice(console.error, `Error: ${data.reason}`);
} catch (error) {
show_notice(console.error, '[addon error] error flagging post!', error);
}
})();
}
// writes to image_data once finished
async 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;
// the href of this element is removed when the original image is loaded
const sample_link = document.querySelector('a#image-link.sample');
const is_sample = sample_link !== null && sample_link.hasAttribute('href');
let res = null;
if (is_sample) {
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) {
if (img.hasAttribute('orig_width') && img.hasAttribute('orig_height')) {
res = [img.getAttribute('orig_width'), img.getAttribute('orig_height')];
}
}
}
if (res === null) {
console.log('[addon] Couldn\'t read resolution from details section, waiting for image size...');
// last resort: try to read natural size
// when loading the original image, this can read the old preview size instead, which shouldn't be a huge deal since
// this happens after image scrolling (see is_done_scrolling()) and the aspect ratio is approximately correct
// TODO: this will however break the manual image scrolling
// TODO: should abort when content failed loading
while ((res = get_resolution(img)) === null)
await sleep(20);
}
data.width = Number(res[0]);
data.height = Number(res[1]);
console.log('[addon] Read image or video resolution ', data.width, 'x', data.height);
data.current_height = data.height;
data.aspect_ratio = data.width / data.height;
image_data = data;
return;
}
// 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(console.error, '[addon error] unknown post content! Can\'t read width/height.');
return;
}
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;
console.log('[addon] Read resolution ', data.width, 'x', data.height);
data.current_height = data.height;
data.aspect_ratio = data.width / data.height;
image_data = data;
}
}
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;
// We can't use transform scale because it doesn't change the DOM size (so the image could be covered up by other elements)
// We also can't use style.width/height because translation notes rely on .width/.height
const set_dimensions = (obj, dim) => {
obj.width = dim.width;
obj.height = dim.height;
};
// reset image size
if (mode === SCALE_MODES.RESET) {
if (!image_data.is_flash) {
set_dimensions(image_data.img_elem, image_data);
adjust_notes();
} else {
set_dimensions(image_data.img_elem, image_data);
set_dimensions(image_data.emb_elem, image_data);
}
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.removeProperty('padding-left');
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.querySelector('.sidebar');
left_side = (sidebar.getBoundingClientRect().right + window.scrollX + 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.scrollX;
}
const target_w = Math.max(window.innerWidth - left_side - get_scrollbar_width(), 1);
const target_h = Math.max(window.innerHeight, 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);
const scaled = {};
if (mode === SCALE_MODES.HORIZONTAL) {
scaled.width = Math.floor(target_w);
scaled.height = Math.floor(target_w / image_data.aspect_ratio);
} else if (mode === SCALE_MODES.VERTICAL) {
scaled.width = Math.floor(target_h * image_data.aspect_ratio);
scaled.height = Math.floor(target_h);
}
if (!always_scale && (config.scale_only_downscale && (scaled.width > image_data.width || scaled.height > image_data.height)))
return;
if (!image_data.is_flash) {
set_dimensions(image_data.img_elem, scaled);
adjust_notes();
} else {
set_dimensions(image_data.img_elem, scaled);
set_dimensions(image_data.emb_elem, scaled);
}
image_data.current_height = scaled.height;
}
function adjust_notes() {
for (const note of unsafeWindow.Note?.all ?? []) {
note.adjustScale(); // this relies on the image's .width and .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() {
window.requestAnimationFrame(() => {
if (image_data === null) return;
const absolute_img_top = Math.round((image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect().top) + window.scrollY;
if (config.scroll_to_image_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);
}
done_scrolling = true;
});
}
// 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, { attributeFilter: ['style'] });
}
function add_highres_listener() {
const img = document.getElementById('image');
if (img === null) return;
const observer = new MutationObserver(() => {
// image is cleared before highres image is loaded
if (img.src === 'about:blank') return;
observer.disconnect();
// re-read image size in case get_resolution() had to be used
read_image_data().then(() => {
if (config.scale_image) scale_image(config.scale_mode, false);
});
});
observer.observe(img, { attributeFilter: ['src'] });
}
async function load_highres() {
if (!config.load_highres) return;
if (config.highres_limit > 0) {
const highres = document.getElementById('highres');
if (highres !== null) {
let size = highres.title; // e.g. "1,738,253 bytes"
size = size.replaceAll(',', '');
size = parseInt(size, 10);
if (size > config.highres_limit) {
return;
}
}
}
while (!is_done_scrolling() || unsafeWindow.Post?.highres === undefined)
await sleep(20);
// mimic sitescript
unsafeWindow.jQuery('a#image-link.sample').unbind('click').removeAttr('href');
unsafeWindow.Post.highres();
}
// simple note fix for Galinoa's Sankaku Channel Dark (only for default image size)
function note_fix() {
if (!config.sankaku_channel_dark_compatibility) return;
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) => {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
config.postpage_hotkeys[e.key.toLowerCase()]?.call(e);
}, true);
}
// sitefix: fix pixiv source links under 'Details'
// issue: source links of the form https://www.pixiv.net/artworks/<id> turn into
// https://www.pixiv.net/artworks/https://www.pixiv.net/artworks/<id>
// doesn't happen for https://www.pixiv.net/en/artworks/<id>
// or the old format https://www.pixiv.net/member_illust.php?mode=medium&illust_id=<id>
function fix_source_link() {
const stats = document.getElementById('stats');
if (stats === null) return;
for (const link of stats.getElementsByTagName('A')) {
if (!link.href) continue;
if (link.href.startsWith('https://www.pixiv.net/artworks/')) {
const id = link.href.substring('https://www.pixiv.net/artworks/'.length);
try {
new URL(id); // throws if not a valid URL
link.href = id;
} catch (ignore) { }
} else {
const match = /https:\/\/pictures.hentai-foundry.com\/[^/]\/([^/]+)\/(\d+)\//.exec(link.href);
if (match) {
const [,user, post_id] = match;
link.href = `https://www.hentai-foundry.com/pictures/user/${user}/${post_id}`;
}
}
}
}
/***********************/
/* 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 = create_button();
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 = create_button();
btn.innerText = 'Copy selection over';
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_status_post_links() {
// TODO https://chan.sankakucomplex.com/user/show/1562990
const random_posts = [...document.querySelectorAll('.highlightable a')].find(a => a.href.endsWith('%20order%3Arandom'));
const username = new URL(random_posts.parentElement.children[0].href).searchParams.get('tags').substring('user:'.length);
const status_post_link = (name, search) => {
const a = document.createElement('A');
const url = new URL(window.location.origin);
const params = new URLSearchParams();
params.append('tags', search);
url.search = params.toString();
a.href = url.href;
a.innerText = name;
return a;
};
insert_node_after(status_post_link('approver', `approver:${username}`), random_posts);
insert_node_after(document.createTextNode(', '), random_posts);
insert_node_after(status_post_link('flagger', `flagger:${username}`), random_posts);
insert_node_after(document.createTextNode(', '), random_posts);
insert_node_after(status_post_link('tagme', `user:${username} tagme`), random_posts);
insert_node_after(document.createTextNode(', '), random_posts);
insert_node_after(status_post_link('flagged', `user:${username} status:flagged`), random_posts);
insert_node_after(document.createTextNode(', '), random_posts);
insert_node_after(status_post_link('unapproved', `user:${username} status:pending`), random_posts);
insert_node_after(document.createTextNode(', '), random_posts);
}
function add_record_template() {
const record_body = document.getElementById('user_record_body');
const record_score = document.getElementById('user_record_score');
if (record_body === null || record_score === null) {
show_notice(console.error, 'couldn\'t find record elements, disabled template');
return;
}
const label = document.createElement('SPAN');
label.innerText = 'Template:';
label.style.marginLeft = '0.5em';
label.style.marginRight = '0.5em';
insert_node_after(label, record_score);
// the JSON is supposed to be an array of 2-or-3-entry-arrays, which converts to Map
const raw_templates = JSON.parse(config.record_template);
const templates = new Map();
for (const [title, content, raw_score] of raw_templates) {
let score = null;
if (typeof raw_score !== 'undefined') {
score = raw_score.toLowerCase();
if (score === 'neutral') score = 0;
else if (score === 'positive') score = 1;
else if (score === 'negative') score = -1;
else score = null;
}
if (score === null) {
show_notice(console.error, '[addon error] record template has invalid score, see console for details', [title, content, raw_score]);
}
templates.set(title, { content, score });
}
const apply_template = (template) => {
record_body.value = template.content;
if (template.score !== null) record_score.value = template.score;
};
const dropdown = create_template_dropdown(templates, apply_template);
dropdown.id = 'template_dropdown';
insert_node_after(dropdown, label);
}
function add_tagscript_presets() {
if (!IndexPage.has_tag_scripts) return;
const mode_menu = document.getElementById('mode-menu');
if (mode_menu === null) {
show_notice(console.error, '[addon error] couldn\'t find "Mode" menu, disabled tagscript presets');
return;
}
// the JSON is supposed to be an array of 2-entry-arrays, which converts to Map
const presets = new Map(JSON.parse(config.tagscript_presets));
if (presets.size === 0)
return;
mode_menu.appendChild(document.createElement('P'));
const label = document.createElement('H5');
label.innerText = 'Tag Script Presets';
mode_menu.appendChild(label);
const set_tag_script = (script) => {
set_cookie('tag-script', script);
select_mode('apply-tag-script');
};
const dropdown = create_template_dropdown(presets, set_tag_script);
dropdown.id = 'tagscript_presets_dropdown';
mode_menu.appendChild(dropdown);
}
function create_template_dropdown(templates, change_event) {
const dropdown = document.createElement('SELECT');
const empty_option = document.createElement('OPTION');
empty_option.disabled = true;
empty_option.selected = true;
empty_option.style.display = 'none';
dropdown.appendChild(empty_option);
for (const [title, value] of templates.entries()) {
const option = document.createElement('OPTION');
option.innerText = title;
// somewhat redundant to the change event but allows to "reset" the text to an already selected template
option.addEventListener('click', (e) => {
change_event(value);
e.preventDefault();
});
dropdown.appendChild(option);
}
dropdown.addEventListener('change', (e) => {
change_event(templates.get(dropdown.value));
e.preventDefault();
});
return dropdown;
}
function add_tag_edit_gear() { // add a "⚙" link to the tag edit page
try {
const h2 = document.querySelector('.title');
const tag = new URL(window.location.href).searchParams.get('title');
const a = document.createElement('A');
a.href = '/tag/edit?name=' + tag;
a.innerText = '⚙';
a.title = 'Edit Tag';
h2.appendChild(a);
} catch (error) {
show_notice(console.error, '[addon error] couldn\'t add "⚙" tag page link, check console', error);
}
}
function add_tag_history_link() { // add a "History »" link to the tag history page
try {
const info = document.querySelector('.tag-information');
const related = document.querySelector('.related-tags');
// use the hidden form field because url can either be e.g. /tag/edit?name=high_resolution or /tag/edit/464292
const tag = document.getElementById('tag_name').value;
const div = document.createElement('DIV');
const h4 = document.createElement('H4');
const a = document.createElement('A');
a.href = '/tag/history?name=' + tag;
a.innerText = 'History »';
div.appendChild(h4);
h4.appendChild(a);
if (related !== null) {
related.insertAdjacentElement('afterend', div);
} else {
info.prepend(div);
}
} catch (error) {
show_notice(console.error, '[addon error] couldn\'t add tag history page link, check console', error);
}
}
function deletion_sanity_checks() { // for duplicates
const thumbs = [...document.querySelectorAll('#content > .deleting-post .thumb')];
if (thumbs.length !== 2)
return; // no parent
const WARNING_TEXT = '<b style="color: crimson">Warning: </b>';
const CHECKED_TAGS = ['upscaled', 'legitimate_variation', 'revision', 'third-party_edit', 'decensored', 'potential_upscale', 'md5_mismatch', 'resolution_mismatch'];
const CHECKED_COMBINATIONS = [['censored', 'uncensored']];
// remove old warnings before re-evaluating
document.querySelectorAll('.SA-warning').forEach((el) => el.remove());
const posts = thumbs.map(get_post_from_thumb);
const warn_tags = posts.map(post => post.tags.filter(tag => CHECKED_TAGS.includes(tag)));
for (const [tag1, tag2] of CHECKED_COMBINATIONS) {
for (let i = 0; i < 2; i++) {
if (posts[i].tags.includes(tag1) && posts[1 - i].tags.includes(tag2)) {
warn_tags[i].push(tag1);
warn_tags[1 - i].push(tag2);
}
}
}
const convert_to_margin = (thumb) => {
const a = thumb.querySelector('a');
const y_diff = a.getBoundingClientRect().top - thumb.getBoundingClientRect().top;
// the thumbnail image is centered in a grid (see adjust_css()), which will move it up when something is added below it
// to fix this we replace the vertical centering with a margin (and allow the thumbnail to scale vertically too)
// fix thumbnail in place vertically
a.style.marginTop = `${y_diff}px`;
thumb.style.alignContent = 'start';
// make thumbnail scale vertically
thumb.style.minHeight = window.getComputedStyle(thumb).height;
thumb.style.height = 'auto';
};
thumbs.forEach(convert_to_margin);
const add_tags_below_thumb = (thumb, tags, missing) => {
for (const tag of tags) {
const span = document.createElement('SPAN');
span.className = 'SA-warning';
span.style.color = 'crimson';
span.style.fontWeight = 'bold';
if (missing) span.style.textDecoration = 'line-through';
span.innerText = tag;
thumb.appendChild(span);
}
};
for (let i = 0; i < thumbs.length; i++) {
add_tags_below_thumb(thumbs[i], warn_tags[i]);
}
if (!posts[0].tags.includes('duplicate')) {
add_tags_below_thumb(thumbs[0], ['duplicate'], true);
}
const integer_multiple = (a, b) => {
if (a < b) return integer_multiple(b, a);
if (a > b && a % b === 0) {
return a / b;
}
return NaN;
};
const widths = [];
const heights = [];
// read resolutions
const res = document.querySelector('#content > .deleting-post > ul > li:nth-child(2)');
for (const b of res.getElementsByTagName('B')) {
const match = /([\d]+)x([\d]+)/.exec(b.innerText);
if (match) {
const [, width, height] = match;
widths.push(Number(width));
heights.push(Number(height));
}
}
// add potential upscale warning
const multiple = integer_multiple(...widths);
if (multiple === integer_multiple(...heights)) {
const span = document.createElement('SPAN');
span.className = 'SA-warning';
span.innerHTML = ` ${WARNING_TEXT} potential ${multiple}x upscale`;
res.insertAdjacentElement('beforeend', span);
}
}
function add_custom_duplicate_delete_reason() {
const reason = document.getElementById('reason');
const custom_reason = document.getElementById('custom_reason');
const thumbs = [...document.querySelectorAll('#content > .deleting-post .thumb')];
if (thumbs.length !== 2)
return; // no parent
const parent_id = thumbs[1].id.substring(1);
const custom_dupe_option = document.createElement('OPTION');
custom_dupe_option.innerText = `duplicate of ${parent_id} (custom reason)`;
reason.appendChild(custom_dupe_option);
reason.addEventListener('change', () => {
const i = reason.selectedIndex;
if (i === 0) return;
const is_custom_dupe = reason.options[i] === custom_dupe_option;
if (is_custom_dupe) {
reason.selectedIndex = 0;
custom_reason.value = `duplicate of ${parent_id} (...)`;
custom_reason.focus();
custom_reason.setSelectionRange(custom_reason.value.length - 4, custom_reason.value.length - 1);
}
});
}
function add_tags_copy_button() {
const tags_not_present = document.querySelector('#content > .deleting-post > ul > li:nth-child(7)');
if (tags_not_present === null) return;
let tags_diff = ' ';
for (const a of tags_not_present.getElementsByTagName('A')) {
const tag = a.innerText.replaceAll(/ /g, '_');
if (['duplicate', 'potential_duplicate'].includes(tag)) continue; // TODO: ignore all meta tags?
tags_diff += tag + ' ';
}
const button = document.createElement('BUTTON');
button.type = 'button';
button.innerText = 'Copy Tags';
button.onclick = () => set_clipboard(tags_diff);
tags_not_present.appendChild(button);
}
function add_post_edit_buttons() {
// cache thumbnail tags
for (const thumb of document.querySelectorAll('.thumb'))
get_post_from_thumb(thumb);
const thumbs = [...document.querySelectorAll('.deleting-post .thumb')];
for (const thumb of thumbs) {
const a = document.createElement('A');
a.innerText = '⚙';
a.style.fontSize = '120%';
a.href = '#';
a.onclick = () => {
open_post_edit_dialog(thumb);
return false;
};
thumb.appendChild(a);
}
}
function add_moderation_search_template() {
const query = document.getElementById('query');
const select = create_template_dropdown(new Map([
['Pending Posts', {add: ['status:pending'], remove: ['order:recently_flagged']}],
['Flagged Posts', {add: ['order:recently_flagged', '-status:pending'], remove: []}]
]), (value) => {
select.selectedIndex = 0;
const search_tags = new Tags(query.value);
for (const tag of value.remove) {
search_tags.remove(tag);
}
for (const tag of value.add) {
search_tags.add(tag);
}
query.value = search_tags.toString() + ' ';
query.focus();
});
const search_button = document.querySelector('#content > form > input:nth-child(2)');
search_button.insertAdjacentElement('afterend', select);
search_button.insertAdjacentText('afterend', ' ');
}
/******************/
/* document-start */
/******************/
await load_config();
// 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.startsWith('/post/index')) pathname = '/'; // normalize post index
// detect page
if (pathname === '/' || pathname.startsWith('/post/similar')) General.page = Page.Index;
else if (pathname.startsWith('/post/show/')) General.page = Page.Post;
else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) General.page = Page.Wiki;
else if (pathname.startsWith('/wiki/show')) General.page = Page.WikiShow;
else if (pathname.startsWith('/tag/edit')) General.page = Page.Tag;
else if (pathname.startsWith('/post/delete')) General.page = Page.Delete;
else if (pathname.startsWith('/post/moderate')) General.page = Page.Moderate;
else if (pathname.startsWith('/user/show')) General.page = Page.User;
else if (pathname.startsWith('/user_record/create')) General.page = Page.Record;
// listen for config changes in other windows
add_storage_change_listener();
modify_css();
// add thumbnail icons and fade out thumbnails
modify_nodes('span.thumb .preview', modify_thumbnail, '.content-page, #recommendations');
switch (General.page) {
case Page.Post:
// mute/pause video
modify_nodes('video#image', node => { configure_video(node); return true; });
break;
}
/******************/
/* content-loaded */
/******************/
async function init() {
add_config_dialog();
if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C');
add_config_button();
update_config_dialog();
update_headerlogo();
useful_beta_link(pathname);
switch (General.page) {
case Page.Index:
IndexPage.init();
add_mode_options();
add_tagscript_presets();
if (config.tag_search_buttons) add_tag_search_buttons();
add_postmode_hotkeys();
add_post_edit_dialog();
update_tag_elements(); // initialize tag menu/buttons
add_tags_change_listener();
break;
case Page.Post:
PostPage.init(pathname);
if (config.view_history_enabled) {
config[HISTORY_KEY].add(Number(PostPage.post_id));
save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history
}
if (config.tag_search_buttons) add_tag_search_buttons();
if (config.tag_category_collapser) add_tag_category_collapser();
add_addon_actions(find_actions_list());
if (config.add_filetype_stat) add_filetype_stat();
fix_source_link();
if (config.move_stats_to_edit_form) move_stats_to_edit_form();
add_tag_buttons('edit-form');
if (config.tag_menu) add_tag_menu();
update_tag_elements(); // initialize tag menu/buttons
add_tags_change_listener();
add_tags_submit_listener(); // specifically for edit-form
add_postpage_hotkeys();
if (config.scale_on_resize) add_scale_on_resize_listener();
read_image_data().then(() => {
if (config.scale_image) scale_image(config.scale_mode, false);
else note_fix();
if (config.scroll_to_image) scroll_to_image();
add_resize_notice_listener();
add_highres_listener();
load_highres();
});
break;
case Page.Wiki:
add_dtext_style_buttons();
add_wiki_template();
break;
case Page.WikiShow:
add_tag_edit_gear();
break;
case Page.Tag:
add_tag_history_link();
break;
case Page.User:
add_status_post_links();
break;
case Page.Record:
add_record_template();
break;
case Page.Delete:
document.getElementById('custom_reason').style.minWidth = '25%';
add_custom_duplicate_delete_reason();
add_tags_copy_button();
add_post_edit_dialog();
add_post_edit_buttons();
update_tag_elements(); // initialize tag menu/buttons
add_tags_change_listener();
deletion_sanity_checks();
break;
case Page.Moderate:
add_moderation_search_template();
break;
}
}
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);