您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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.
当前为
// ==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 1.0.2 // @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 = 'v1.0.2'; 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 = { 'r18+': EXPLICIT_SVG, 'r15+': QUESTIONABLE_SVG, 'g': 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', }; const TAG_CATEGORIES = [ 'copyright', 'studio', 'character', 'artist', 'medium', 'meta', 'genre' ]; /*****************/ /* 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) { return Promise.resolve(GM.addStyle(css)); // Violentmonkey returns a style element whereas Tampermonkey returns a Promise } else { const sheet = document.createElement('STYLE'); sheet.innerText = css; document.head.appendChild(sheet); return Promise.resolve(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: { key: value.key, 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' && value !== null) { 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.key, value.v.modifiers); } } return value; } function adjust_tag_color_css() { for (const category of TAG_CATEGORIES) { if (!config.tag_category_colors.has(category)) continue; add_style(` .tag_button.tag-type-${category}:not(.tag_nonexistent) { color: ${config.tag_category_colors.get(category)}; } `); } } let css_vars = null; async function update_css_vars() { const style = await add_style(` :root { --thumbnail-size: ${config.thumbnail_size}px; } `); css_vars?.remove(); css_vars = style; } 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; }'); /* shrink size of tag edit buttons */ add_style('tr input[type="submit"], tr button { min-width: unset; }'); /* 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: there can appear a small gap between the navbar <li>s and the <ul>s which partially breaks hovering */ /* this issue seems to be font-dependent and is worsened by the unicode config gear icon, no matter it's size (...?!) */ add_style(` div#header ul#navbar { margin-bottom: 0; padding-top: 2px; padding-bottom: 0; } div#header ul#navbar li { padding-bottom: 3px; } div#header ul#navbar li:hover > ul { height: 2.1em; } `); /* 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; } `); adjust_tag_color_css(); // disable site icons when using own icons if (config.show_speaker_icon || config.show_animated_icon) { add_style(` .sound-icon, .video-icon { display: none; } `); } update_css_vars(); if (config.custom_style) { add_style(` :root { /* 1920px - 230px sidebar - scrollbar = ~1670px */ --thumbnail-image-size: calc(var(--thumbnail-size) - 30px); } span.thumb { /* add outline */ outline: #84848463 dashed 1px; /* slight transparent background */ background-color: #c4c4c421; /* set size */ width: var(--thumbnail-size); } .popular-preview-post { width: var(--thumbnail-size); } #popular-preview span.thumb { height: var(--thumbnail-size); } /* scale thumbnail images */ span.thumb img { /* * using object-fit: contain; instead will cause the borders to not fit the image * but this way will make the crossed out eyes appear tiny... */ width: auto; height: auto; max-width: var(--thumbnail-image-size); max-height: var(--thumbnail-image-size); } /* fix "more popular posts" link positioning. */ .popular-preview-post { height: auto; } #more-popular-link { padding-top: 0; } `); if (General.page !== Page.Post) { add_style(` /* use full screen width */ div.content { width: calc(100% - 230px); } span.thumb { height: var(--thumbnail-size); } `); } else { add_style(` span.thumb { height: auto; padding-top: 11px; padding-bottom: 11px; } `); } } if (config.enlarge_navbar) { add_style(` :root { --navbar-size: 4em; --subnav-size: 4em; } /* Grow navbar size to match hover area and equalize the latter for darkmode */ div#header ul#subnavbar { height: calc(var(--navbar-size) - 1em + 1px); } div#header ul#navbar li:hover > ul { height: var(--subnav-size); } `); } } /***************************/ /* 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: () => { if (PostPage.post_id === null) { show_notice(console.error, 'addon error: no post id, report to author!'); return; } open_in_tab(window.location.origin + '/posts/similar?id=' + PostPage.post_id); }, open_delete: () => { if (!found_delete_action) { show_notice(console.error, 'addon error: Delete action not found, no permission?'); } else { if (PostPage.post_id === null) { show_notice(console.error, 'addon error: no post id, report to author!'); return; } open_in_tab(window.location.origin + '/posts/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, key, modifiers) { this.key = key; this.modifiers = modifiers ?? new Set(); this.action_name = action_name; this.action = get_hotkey_action(action_name); } call(e) { if (e.key.toLowerCase() === this.key && 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, redirect_v_to_s_server: false, show_speaker_icon: false, show_animated_icon: false, show_ratings_icon: false, post_border_style: 0, custom_style: true, thumbnail_size: 208, enlarge_navbar: false, setparent_deletepotentialduplicate: false, editform_deleteuselesstags: false, hide_headerlogo: false, tag_search_buttons: true, tag_post_counts: 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"]} ]', 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: 1, collapsed_tag_categories: new Set(), move_stats_to_edit_form: false, postpage_hotkeys: [ new Hotkey('reset_size', 'h'), new Hotkey('fit_size', 'g'), new Hotkey('fit_horizontal', 'g', new Set(['shift'])), new Hotkey('fit_vertical', 'g', new Set(['alt'])), //new Hotkey('open_similar', 's'), //new Hotkey('open_delete', 'd'), // site: shift+d //new Hotkey('add_translation', 't'), // site: n new Hotkey('copy_translations', 'c'), new Hotkey('paste_translations', 'v'), ], indexpage_hotkeys: [ new Hotkey('set-parent_mode', 'v'), new Hotkey('choose-parent_mode', 'c'), new Hotkey('rating-s_mode', 's'), new Hotkey('rating-q_mode', 'q'), new Hotkey('rating-e_mode', 'e'), ], notes: [], save_tag_categories: true, tag_categories: new Map(), tag_category_colors: new Map(), use_old_wiki: false, use_old_pools: false, use_old_tags_index: false, }; for (let i = 1; i <= 10; i++) { const key = i < 10 ? String(i) : '0'; DEFAULT_CONFIG.indexpage_hotkeys.push(new Hotkey(`select_tagscript_preset${i}`, key)); } 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, thumbnail_size: 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); // "migrate" from old hotkey format by resetting to default if ((key === 'postpage_hotkeys' || key === 'indexpage_hotkeys') && !Array.isArray(value)) { reset_setting(key); continue; } } 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 === 'thumbnail_size') { update_css_vars(); } else 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', 'redirect_v_to_s_server', ], }, general: { name: 'General', entries: [ 'tag_search_buttons', 'or_tag_search_button', 'tag_post_counts', 'tag_category_collapser', 'tag_category_collapser_style', 'show_speaker_icon', 'show_animated_icon', 'show_ratings_icon', 'view_history_enabled', 'post_border_style', 'custom_style', 'thumbnail_size', 'enlarge_navbar', 'hide_headerlogo', ], }, editing: { name: 'Editing', entries: [ 'use_old_wiki', 'use_old_pools', 'use_old_tags_index', 'move_stats_to_edit_form', 'setparent_deletepotentialduplicate', 'editform_deleteuselesstags', 'tag_menu', COMMON_TAGS_KEY, 'tag_menu_layout', 'save_tag_categories', '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'}, redirect_v_to_s_server: {type: 'checkbox', desc: 'Redirect v.sankakucomplex.com to s.sankakucomplex.com'}, tag_post_counts: {type: 'checkbox', desc: 'Add old style post tag counts'}, 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'}}, custom_style: {type: 'checkbox', desc: 'Custom thumbnail grid style', title: 'Previously known as \'Sankaku...Something\' userstyle'}, thumbnail_size: {type: 'range', desc: 'Thumbnail size for custom style: ', min: 30, max: 330}, setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'}, enlarge_navbar: {type: 'checkbox', desc: 'Enlarge navbar', title: 'Makes it easier to reach far options without accidentally closing the subnav.'}, editform_deleteuselesstags: {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag'}, 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: 'Compact category name', 2: 'Default category name'}}, hide_headerlogo: {type: 'checkbox', desc: 'Hide header logo'}, 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'}}, save_tag_categories: {type: 'checkbox', desc: 'Save tag color information for use in tag menu'}, 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).'}, move_stats_to_edit_form: {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form'}, use_old_wiki: {type: 'checkbox', desc: 'Use old wiki where possible'}, use_old_pools: {type: 'checkbox', desc: 'Use old pools instead of books (read-only)'}, use_old_tags_index: {type: 'checkbox', desc: 'Use old tags index'}, // TODO this is a mess '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 === 'range' || 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 is_config_dialog_visible() { return document.getElementById('cfg_dialog').style.display !== 'none'; } 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, obj.key, 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 PostFlags= Symbol('post flags'); static Wiki = Symbol('wiki add/edit'); static WikiShow = Symbol('wiki show'); static Tag = Symbol('tag edit'); static TagIndex = Symbol('tag index'); 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_url(tags) { const url = new URL(window.location.origin); const params = new URLSearchParams(); params.append('tags', tags); url.search = params.toString(); return url.href; } 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 'range': innerDivHTML += generate_span(); innerDivHTML += `<input id="${KEY_PREFIX}${key}" type="range" min="${value.min}" max="${value.max}" style="vertical-align: middle;">`; break; case 'text': innerDivHTML += generate_span(); innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" rows=8 style='width: 100%; box-sizing: border-box; max-width: unset; margin-top: 0;'></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"> Most settings require 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 = () => { if (window.confirm('Are you sure?\nThis will reset ALL settings, including templates and the tag menu tags!')) { reset_config(); show_notice(console.log, '[addon] reset all settings'); } return false; }; document.getElementById('history_clear').onclick = () => { if (window.confirm('Are you sure you want to clear the post view history?')) { reset_setting(HISTORY_KEY); show_notice(console.log, '[addon] reset post view history'); } 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.push(new Hotkey(name, key, 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 hotkey of config[`${page}_hotkeys`]) { const key_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_key`); if (key_el !== null) key_el.value = hotkey.key; for (const mod of ['ctrl', 'alt', 'shift']) { const mod_el = document.getElementById(`${KEY_PREFIX}${page}_hotkeys.${hotkey.action_name}_${mod}`); if (mod_el !== null) mod_el.checked = hotkey.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 a = document.createElement('A'); a.href = '#'; a.onclick = () => { show_config_dialog(true); return false; }; a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config'; // close when clicking outside document.addEventListener('click', (e) => { if (is_config_dialog_visible()) { if (e.target.closest('#cfg_dialog') !== null) return; // clicked inside show_config_dialog(false); e.preventDefault(); } }, true); const li = document.createElement('LI'); li.className = 'lang-select'; // match style of top bar li.appendChild(a); navbar.appendChild(document.createTextNode('\u00A0')); // add nbsp 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); taglink.parentNode.insertBefore(a, taglink); taglink.parentNode.insertBefore(document.createTextNode(' '), taglink); }; add_search_button('+'); add_search_button('-'); if (config.or_tag_search_button) add_search_button('~'); } } function add_tag_post_counts() { for (const item of document.querySelectorAll('#tag-sidebar li div')) { const post_count = item.querySelector('span.tooltip > span:nth-child(1) > span:nth-child(2)'); if (post_count === null) continue; const span = document.createElement('span'); span.style.color = 'grey'; span.innerText = post_count.innerText; item.appendChild(span); } } // needs to run before add_tag_search_buttons function collect_tag_categories() { for (const category of TAG_CATEGORIES) { for (const li of document.querySelectorAll(`.tag-type-${category}`)) { const taglink = li.querySelector('a'); if (taglink === null) continue; const tag = get_search_tags(taglink).toString(); config.tag_categories.set(tag, category); config.tag_category_colors.set(category, window.getComputedStyle(taglink, null).getPropertyValue('color')); } } adjust_tag_color_css(); // add another copy to reflect newly learned colors if (config.save_tag_categories) { // TODO this has the same storage race condition issue as the view history and the solution is way too complex and inefficient to implement here... save_setting('tag_categories', config.tag_categories); save_setting('tag_category_colors', config.tag_category_colors); } } 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 ([0, 2].includes(config.tag_category_collapser_style)) { // 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; // remove default category names for compact styles if (config.tag_category_collapser_style < 2) { for (const title of document.querySelectorAll('#tag-sidebar > h6, #tag-sidebar > br')) { title.remove(); } } 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 ([0, 2].includes(config.tag_category_collapser_style)) { // 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) const internalPost = unsafeWindow.Post?.posts[post_id]; if (internalPost !== undefined) { const post = { ...internalPost }; const fix_rating = (rating) => { const rating_translation = { 's': 'g', 'q': 'r15+', 'e': 'r18+' }; if (rating_translation.hasOwnProperty(rating)) return rating_translation[rating]; return rating; }; post.rating = fix_rating(post.rating); return post; } const 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; 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).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('/posts/show')) { betaLink.pathname = pathname; betaLink.search = window.location.search; // user page, /users/show/<id> or /users/show?name=<username> -> /user/show?name=<username> } else if (pathname.startsWith('/users/show')) { const username = document.querySelector('.user-show-heading > h2')?.innerText.replaceAll(' ', '_'); betaLink.pathname = '/user/show'; betaLink.searchParams.set('name', username); // 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, /pools/show/<id> -> /books/<id> } else if (pathname.startsWith('/pools/show/')) { betaLink.pathname = '/books/' + pathname.substring('/pools/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'); // TODO: currently broken due to requiring an authenticity token 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" style="margin-top: 0.5em;" ></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) => ({ 'g': 'safe', 'r15+': 'questionable', 'r18+': '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.origin), { 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; } for (const hotkey of config.indexpage_hotkeys) { hotkey.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() { PostPage.post_id = document.getElementById('hidden_post_id')?.innerText; PostPage.parent_id = document.getElementById('post_parent_id')?.value; } } // original post/parent ids let image_data = null; let resize_timer; let tag_update_timer; // 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 (config.tag_categories.has(tag)) a.classList.add(`tag-type-${config.tag_categories.get(tag)}`); 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 = () => { 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('/posts/similar') || pathname.startsWith('/post/similar')) { actions_ul = a.parentElement.parentElement; } else if (pathname.startsWith('/posts/delete/') || 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_flagger_links() { for (const a of document.querySelectorAll('.flag-and-reason-count > a')) { const username = a.innerText; const flagged = document.createElement('A'); flagged.innerText = 'flagged'; flagged.href = get_search_url(`flagger:${username}`); a.insertAdjacentText('afterend', ')'); a.insertAdjacentElement('afterend', flagged); a.insertAdjacentText('afterend', ' ('); } } function link_to_post_tag_history() { if (PostPage.post_id === null) return; for (const a of document.querySelectorAll('#subnavbar > li > a')) { try { const url = new URL(a.href); if (url.pathname.startsWith('/post_tag_history/index')) { url.search = 'post_id=' + PostPage.post_id; a.href = url; } } catch (ignored) {} } } 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() + ' '; 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() + ' '; update_tag_elements(skip_common_tags_update); } function delete_useless_tags_tag() { if (config.editform_deleteuselesstags) remove_post_tag('useless_tags'); } function reset_tags() { get_post_tags_el().value = get_post_old_tags_el().value; 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 (post_id === null) { show_notice(console.error, '[addon] no post id, report to author!'); return; } 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; const flag_reason = document.getElementById('other_reason'); const flag_confirm = document.getElementById('confirm'); const flag_form = flag_reason?.parentElement; if (flag_reason === null || flag_confirm === null) { show_notice(console.error, '[addon error] couldn\'t find flag form'); return; } flag_reason.value = reason; flag_confirm.checked = true; (async function() { try { const response = await fetch(new URL(flag_form.action, document.location.origin), { method: 'POST', body: new FormData(flag_form), }); if (!response.ok) { show_notice(console.error, `[addon error] non-OK status code ${response.status}`); return; } show_notice(console.log, 'Post was resent to moderation queue'); } 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; return; } const 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(); } function redirect_v_to_s_server() { const img = document.getElementById('image'); if (img === null) return; const url = new URL(img.src); if (url.host === 'v.sankakucomplex.com') { url.host = 's.sankakucomplex.com'; img.src = url; } } function add_postpage_hotkeys() { document.addEventListener('keydown', (e) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return; for (const hotkey of config.postpage_hotkeys) { hotkey.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() { const random_posts = [...document.querySelectorAll('.highlightable a')].find(a => a.href.endsWith('%20order%3Arandom')); if (!random_posts) return; const user_posts = random_posts.parentElement.children[0].href; const username = new URL(user_posts).searchParams.get('tags').substring('user:'.length); const status_post_link = (name, search) => { const a = document.createElement('A'); a.href = get_search_url(search); a.innerText = name; return a; }; let dateSearch = ''; if (get_username() !== username) { // not your own profile // Thanks to Evaera // add leeway of 3 days to tag count searches (should be 2 days but date search goes by server time, which can differ from local UTC by up to 14 hours) const threeDaysAgo = new Date(); threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); const dd = String(threeDaysAgo.getUTCDate()).padStart(2, '0'); const mm = String(threeDaysAgo.getUTCMonth() + 1).padStart(2, '0'); const yyyy = threeDaysAgo.getUTCFullYear(); dateSearch = ` date:<${yyyy}-${mm}-${dd}`; } 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('artist_tag_count:0', `user:${username} artist_tag_count:0${dateSearch}`), random_posts); insert_node_after(document.createTextNode(', '), random_posts); insert_node_after(status_post_link('general_tag_count:<13', `user:${username} general_tag_count:<13${dateSearch}`), random_posts); insert_node_after(document.createTextNode(', '), random_posts); insert_node_after(status_post_link('tagme', `user:${username} tagme${dateSearch}`), 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 = '/tags/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. /tags/edit?name=high_resolution or /tags/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_histories?tag_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(' ', '_'); 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 > button'); search_button.insertAdjacentElement('afterend', select); search_button.insertAdjacentText('afterend', ' '); } function get_lang_path() { let lang = document.getElementById('lang-sel')?.value; if (lang === null && new URL(document.location).pathname[3] === '/') { lang = new URL(document.location).pathname.slice(0, 3); } if (lang === null || lang === '/en') lang = ''; return lang; } function old_wiki_tag_links() { if (!config.use_old_wiki) return; const lang = get_lang_path(); if (lang === '/ja') return; // old wiki cannot be accessed by japanese tag names (and there seems to be no way to get the english tag names) const revert_beta_link = (a, is_tag_edit_link) => { try { const url = new URL(a.href); if (url.hostname !== 'beta.sankakucomplex.com') return; const tag = url.searchParams.get('tagName'); if (url.pathname.startsWith('/tag/history')) { a.href = new URL(`${lang}/tag_histories?tag_name=${tag}`, document.location.origin).href; } else if (url.pathname.startsWith('/tag/')) { // tag index "Edit" link if (is_tag_edit_link) { a.href = new URL(`${lang}/tags/edit?name=${tag}`, document.location.origin).href; } else { a.href = new URL(`${lang}/wiki/show?title=${tag}`, document.location.origin).href; } } else if (url.pathname === '/') { const tags = url.searchParams.get('tags'); a.href = new URL(`${lang}/?tags=${tags}`, document.location.origin).href; } } catch (e) { console.error('[addon error] failed reverting beta link', a.href, e); } }; if (General.page === Page.TagIndex) { document.querySelectorAll('tbody td:nth-child(6) a').forEach(a => revert_beta_link(a, true)); document.querySelectorAll('tbody a').forEach(revert_beta_link); } else { document.querySelectorAll('.tooltip a').forEach(revert_beta_link); } } function old_wiki_subnav_links() { if (!config.use_old_wiki) return; const lang = get_lang_path(); const tag = new URL(document.location).searchParams.get('title'); if (!tag) { show_notice(console.error, '[addon error] cannot find wiki tag'); return; } for (const a of document.querySelectorAll('#subnavbar a')) { const url = new URL(a.href); if (url.hostname !== 'beta.sankakucomplex.com') continue; if (a.pathname === '/wiki/edit_article') { a.href = new URL(`${lang}/wiki/edit?title=${tag}`, document.location.origin).href; } else if (a.pathname === '/wiki/article_history') { a.href = new URL(`${lang}/wiki/history?title=${tag}`, document.location.origin).href; } else if (a.pathname === '/wiki/create_article') { a.href = new URL(`${lang}/wiki/add?title=${tag}`, document.location.origin).href; } else if (a.pathname === '/') { a.href = new URL(`${lang}/?tags=${tag}`, document.location.origin).href; } } } function old_pools_post_link() { if (!config.use_old_pools) return; for (const el of document.querySelectorAll('.status-notice')) { if (el.id.startsWith('pool')) { const pool_id = el.id.substring(4); for (const a of el.querySelectorAll('a')) { const url = new URL(a.href); if (url.hostname !== 'beta.sankakucomplex.com') continue; a.href = new URL(`${get_lang_path()}/pools/show/${pool_id}`, document.location.origin).href; break; } } } } function old_nav_links() { for (const a of document.querySelectorAll('#navbar a')) { const url = new URL(a.href); if (url.hostname !== 'beta.sankakucomplex.com') continue; let reset_color = false; if (config.use_old_pools && a.pathname === '/books') { a.href = new URL(`${get_lang_path()}/pools/index`, document.location.origin).href; reset_color = true; } else if (config.use_old_tags_index && a.pathname === '/tags') { a.href = new URL(`${get_lang_path()}/tags/index`, document.location.origin).href; reset_color = true; } if (reset_color) { const font = a.querySelector('font'); if (font) font.style.color = 'unset'; } } } /******************/ /* document-start */ /******************/ await load_config(); let pathname = window.location.pathname; // strip language codes in pathnames like "/ja/post/show" if (pathname.indexOf('/', 1) === 3) pathname = pathname.substring(3); // normalize old singulars to plurals for (const path of ['post', 'user', 'user_record', 'tag']) { if (pathname.startsWith(`/${path}/`) || pathname === '/' + path) pathname = `/${path}s` + pathname.slice(5); // normalize post path } // normalize post index if (pathname.startsWith('/posts/index') || pathname === '/posts') pathname = '/'; // detect page if (pathname === '/' || pathname.startsWith('/posts/similar')) General.page = Page.Index; 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('/tags/edit')) General.page = Page.Tag; else if (pathname.startsWith('/tags/index') || ['/tags/', '/tags'].includes(pathname)) General.page = Page.TagIndex; else if (pathname.startsWith('/posts/delete')) General.page = Page.Delete; else if (pathname.startsWith('/posts/moderate')) General.page = Page.Moderate; else if (pathname.startsWith('/posts/flags')) General.page = Page.PostFlags; else if (pathname.startsWith('/posts/')) General.page = Page.Post; else if (pathname.startsWith('/users/show')) General.page = Page.User; else if (pathname.startsWith('/user_records/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); old_wiki_tag_links(); old_nav_links(); switch (General.page) { case Page.Index: IndexPage.init(); add_mode_options(); add_tagscript_presets(); collect_tag_categories(); if (config.tag_search_buttons) add_tag_search_buttons(); if (config.tag_post_counts) add_tag_post_counts(); add_postmode_hotkeys(); add_post_edit_dialog(); update_tag_elements(); // initialize tag menu/buttons add_tags_change_listener(); break; case Page.Post: PostPage.init(); // reading the post id can fail when rate limited (or when the website changes) if (config.view_history_enabled && PostPage.post_id !== null) { config[HISTORY_KEY].add(Number(PostPage.post_id)); save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history } collect_tag_categories(); if (config.tag_search_buttons) add_tag_search_buttons(); if (config.tag_post_counts) add_tag_post_counts(); if (config.tag_category_collapser) add_tag_category_collapser(); add_addon_actions(find_actions_list()); add_flagger_links(); link_to_post_tag_history(); 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(); if (config.redirect_v_to_s_server) redirect_v_to_s_server(); read_image_data().then(() => { if (config.scale_image) scale_image(config.scale_mode, false); if (config.scroll_to_image) scroll_to_image(); add_resize_notice_listener(); add_highres_listener(); load_highres(); }); old_pools_post_link(); break; case Page.Wiki: add_dtext_style_buttons(); add_wiki_template(); break; case Page.WikiShow: add_tag_edit_gear(); old_wiki_subnav_links(); break; case Page.Tag: add_tag_history_link(); break; case Page.TagIndex: old_wiki_tag_links(); 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);