您需要先安装一个扩展,例如 篡改猴、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 0.99.34 // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZHPKwRhGMc/dolYbeIgOewBOeyWKDlqFZflsFZZXGZmZ3fVzphmZpNclYuDchAXvw7+A67KlVKKlOTgL/DrIo3n3VUr8U7vPJ++7/t9et/vC6FUybC8+gGwbN9NTyRjs9m5WOMTETpoox00w3Mmp8cz/Dveb6hT9Tqhev2/78/RkjM9A+qahIcNx/WFR4VTy76jeEO4wyhqOeF94bgrBxS+ULpe5UfFhSq/KnYz6TEIqZ6xwg/Wf7BRdC3hfuEeq1Q2vs+jbhIx7ZlpqV0yu/FIM0GSGDplFinhk5BqS2Z/+wYqvimWxGPI32EFVxwFiuKNi1qWrqbUvOimfCVWVO6/8/TyQ4PV7pEkNDwEwUsvNG7B52YQfBwEwechhO/hzK75lySnkTfRN2tazx5E1+DkvKbp23C6Dp13juZqFSksM5TPw/MxtGah/Qqa56tZfa9zdAuZVXmiS9jZhT7ZH134ArhcZ+m/WStSAAAACXBIWXMAAAsSAAALEgHS3X78AAAAeElEQVQ4y2NgoCX4Xyb7H4TxqWHCo7keG5toA4CgAQebsAHYbMTlCiYibMfrCiZibcIlx0SE3/GGBRM+Gxi7HjeCMD41TESGPE5XMOGzHRsbnxcIxbsDDAMts4cbjmR7A4kpvQHkMiZCKY1QSmXBZTKedNBA1RwLAFCeNCTVhz2FAAAAAElFTkSuQmCC // @match https://chan.sankakucomplex.com/* // @match https://idol.sankakucomplex.com/* // @run-at document-start // @noframes // @grant GM.registerMenuCommand // @grant GM_registerMenuCommand // @grant GM.addStyle // @grant GM.openInTab // @grant GM.setValue // @grant GM.getValue // @grant GM.deleteValue // @grant GM.addValueChangeListener // @grant GM_addValueChangeListener // @grant unsafeWindow // ==/UserScript== (async function(unsafeWindow) { 'use strict'; const VERSION = 'v0.99.34'; // based on the Tag Checklist in the wiki const DEFAULT_TAGLIST = `[ { "name": "People & Gender", "tags": [ [ ["female female_only 1girl 2girls 3girls 4girls 5girls 6+girls"], ["male male_only 1boy 2boys 3boys 4boys 5boys 6+boys"], ["futanari futanari_only 1_futanari 2_futanari 3_futanari 4_futanari 5_futanari 6+_futanari"], ["newhalf newhalf_only 1_newhalf 2_newhalf 3_newhalf 4_newhalf 5_newhalf 6+_newhalf"] ], "no_humans" ] }, { "name": "Young Age", "tags": [ "child loli shota toddlercon young" ] }, { "name": "Androgynous", "tags": [ "androgynous crossdressing genderswap trap reverse_trap" ] }, { "name": "Group", "tags": [ "solo duo trio quartet quintet sextet group" ] }, { "name": "Relationship", "tags": [ "couple siblings sisters brothers brother_and_sister twins triplets" ] }, { "name": "Who/Other", "tags": [ "anthropomorphization multiple_persona elf mecha monster monster_girl magical_girl fairy" ] }, { "name": "Face", "tags": [ "face ears eyes nose lips teeth facial_mark facial_hair beard" ] }, { "name": "Upper Body", "tags": [ "arms armpits armpit_crease armpit_peek back bare_shoulders breasts bust clavicle midriff navel stomach hands fingers" ] }, { "name": "Lower Body", "tags": [ "anus ass mound_of_venus vagina penis thighs knees feet barefoot legs bare_legs bare_thighs zettai_ryouiki toes" ] }, { "name": "Breasts", "tags": [ "cleavage breasts nipples areolae puffy_areolae areola_slip breasts_out_of_clothes breasts_apart underboob sideboob" ] }, { "name": "Breast Size", "tags": [ "pettanko small_breasts medium_breasts huge_breasts large_breasts gigantic_breasts alternate_bust_size" ] }, { "name": "Skin Color", "tags": [ "albino pale_skin dark_skin tanned red_skin blue_skin shiny_skin" ] }, { "name": "Hairstyle", "tags": [ "ahoge bangs blunt_bangs bob_cut double_bun drill_hair hair_over_one_eye peek-a-boo_bang ponytail side_pony_tail single_braid spiky_hair twinbraids twintails alternate_hairstyle two_side_up" ] }, { "name": "Hair Length", "tags": [ "very short hair short_hair medium_hair long_hair very_long_hair absurdly long hair" ] }, { "name": "Hair/Eye Color", "tags": [ [ ["blonde black_hair blue_hair brown_hair green_hair grey_hair orange_hair pink_hair purple_hair red_hair silver_hair"], ["golden_eyes black_eyes blue_eyes brown_eyes green_eyes grey_eyes orange_eyes pink_eyes purple_eyes red_eyes silver_eyes"] ], "white_hair" ] }, { "name": "Animal Parts", "tags": [ "animal_ears bat_wings bunny_ears cat_tail wolf_ears fang horns kitsunemimi nekomimi tail inumimi wings angel_wings" ] }, { "name": "Look/Other", "tags": [ "chibi mole muscle pointed_ears pregnant scar curvy animal_ear_fluff fluffy_tail" ] }, { "name": "Swimwear", "tags": [ "bikini one-piece_swimsuit swimsuit competition_swimsuit sukumizu" ] }, { "name": "Facewear", "tags": [ "megane sunglasses eyewear_on_head red-framed_eyewear" ] }, { "name": "Upper Body", "tags": [ "sailor_collar choker shirt crop_top camisole dress bra babydoll" ] }, { "name": "Lower Body", "tags": [ "skirt pleated_skirt pantsu thighhighs shoes sandals socks pants shorts short_shorts" ] }, { "name": "Traditional\u00A0Clothes", "tags": [ "serafuku kimono kindergarten_uniform chinese_clothes" ] }, { "name": "Wear/Other", "tags": [ "armor suit uniform school_uniform underwear_only nude completely_nude" ] }, { "name": "Actions", "tags": [ "battle fighting jumping running princess_carry stretch sleeping lying flying squatting" ] }, { "name": "Posture", "tags": [ "all_fours arched_back back-to-back bent-over fighting_stance leaning leaning_back leaning_forward squat top-down_bottom-up" ] }, { "name": "Arms", "tags": [ "arms_behind_back arms_crossed arm_support arm_up arms_up arms_behind_head chin_rest outstretched_arm outstretched_arms spread_arms v_arms" ] }, { "name": "Hands", "tags": [ "hands_clasped hand_in_pocket hands_in_pocket hand_on_cheek hand_on_hat hand_on_head hand_on_hip hands_on_hip hand_on_shoulder holding_hands interlocked_fingers outstretched_hand" ] }, { "name": "Legs", "tags": [ "knees_on_chest leg_lift leg_up legs_up outstretched_leg pigeon_toed spread_legs" ] }, { "name": "Sitting", "tags": [ "sitting crossed_legs indian_style leg_hug seiza sitting_on_lap sitting_on_person wariza yokozuwari straddling" ] }, { "name": "Standing", "tags": [ "standing crossed_legs_(standing) standing_on_one_leg" ] }, { "name": "Lying", "tags": [ "lying on_back on_side on_stomach" ] }, { "name": "Viewing Direction", "tags": [ "eye_contact looking_at_viewer looking_back looking_away" ] }, { "name": "Gesture", "tags": [ "clenched_hand clenched_hands double_v heart_hands pinky_out pointing shushing thumbs_up \\\\n\\/ \\\\m\\/ reaching salute waving cat_pose paw_pose v claw_pose double_\\\\m\\/" ] }, { "name": "Facial Expressions", "tags": [ "expressions expressionless ahegao anger_vein blush blush_stickers clenched_teeth closed_eyes evil naughty_face nosebleed open_mouth parted_lips pout rolleyes frown tears scream" ] }, { "name": "Emotions", "tags": [ "angry annoyed embarassed happy sad scared surprised worried disappointed", "drunk, trembling" ] }, { "name": "Sex", "tags": [ "sex anal clothed_sex happy_sex vaginal yaoi yuri tribadism oral" ] }, { "name": "Positions", "tags": [ "69 doggystyle girl_on_top cowgirl_position reverse_cowgirl_position upright_straddle missionary" ] }, { "name": "Stimulation", "tags": [ "buttjob footjob grinding thigh_sex tekoki caressing_testicles double_handjob masturbation crotch_rub paizuri naizuri" ] }, { "name": "Oral", "tags": [ "oral breast_sucking cunnilingus facesitting fellatio deepthroat :>=" ] }, { "name": "Groping", "tags": [ "groping ass_grab breast_grab nipple_tweak self_fondle torso_grab" ] }, { "name": "Group Sex", "tags": [ "group_sex gangbang double_penetration orgy spitroast teamwork threesome" ] }, { "name": "Insertion", "tags": [ "insertion anal_insertion large_insertion stomach_bulge multiple_insertions urethral_insertion penetration nipple_penetration fingering anal_fingering" ] }, { "name": "Fetishes", "tags": [ "milf giantess minigirl plump fat skinny public public_nudity zenra exhibitionism voyeurism futa_on_female futa_on_male incest twincest rape about_to_be_raped molestation bestiality impregnation tentacles virgin vore" ] }, { "name": "Bondage", "tags": [ "bondage bdsm asphyxiation breast_bondage shibari spreader_bar suspension femdom humiliation body_writing slave spanked torture bound_arms bound_legs bound_wrists suspension" ] }, { "name": "Semen", "tags": [ "semen bukkake dripping_semen semen_splatter semen_pool nakadashi semen_in_anus semen_in_mouth semen_on_tongue semen_on_body semen_on_hair semen_on_lower_body semen_on_ass semen_on_vagina semen_on_upper_body semen_on_breasts semen_on_clothes ejaculation ejaculating_while_penetrated facial" ] }, { "name": "Objects", "tags": [ "condom used_condom sex_toy dildo vibrator" ] }, { "name": "Bodily Fluids", "tags": [ "blood lactation urinating saliva sweat female_ejaculation vaginal_juices" ] }, { "name": "View", "tags": [ "cross-section internal_cumshot x-ray" ] }, { "name": "Background", "tags": [ "white_background pink_background blue_background simple_background gradient_background two-tone_background ambiguous_background" ] }, { "name": "Placement", "tags": [ "indoors outdoors rooftop city pool beach cave bedroom hallway" ] }, { "name": "Nature", "tags": [ "ocean river tree palm_tree wisteria lilac white_flower blue_flower grass sand water" ] }, { "name": "Indoors", "tags": [ "pillow bed door bed_sheet counter window curtains bathtub" ] }, { "name": "Work Type", "tags": [ "scan watercolor_(medium) papercraft non-web_source photoshop_(medium) sketch work_in_progress lineart" ] } ]`; /*****************/ /* compatibility */ /*****************/ let IS_GREASEMONKEY4 = false; // script breaking changes (see TODO) let IS_MONKEY = false; // Tampermonkey, Violentmonkey, Greasemonkey (all at least partially support 'GM.' functions) if (typeof GM !== 'undefined' && typeof GM.info === 'object') { IS_MONKEY = true; IS_GREASEMONKEY4 = (GM.info.scriptHandler === 'Greasemonkey'); // polyfills for ViolentMonkey if (typeof GM.addValueChangeListener === 'undefined') GM.addValueChangeListener = GM_addValueChangeListener; if (typeof GM.registerMenuCommand === 'undefined') GM.registerMenuCommand = GM_registerMenuCommand; // doesn't support accessKey } function open_in_tab(url) { if (IS_MONKEY) GM.openInTab(url, false); else window.open(url); // requires popup permission } function add_style(css) { if (IS_MONKEY) { GM.addStyle(css); } else { const sheet = document.createElement('STYLE'); sheet.innerText = css; document.head.appendChild(sheet); } } // the site uses a ton of ancient, non-standard polyfills/prototype overrides, e.g. // Array.from(new Set([1])) returns [] instead of [1] // JSON.parse(JSON.stringify([1])) returns "[1]" instead of [1] // Array.from(s) can be replaced by [...s] // to use proper JSON we need to temporarily unbind the site's toJSON functions const toJSON_OBJECTS = [Object, Array.prototype, Number.prototype, String.prototype]; function delete_toJSONs() { const toJSON_originals = []; for (const obj of toJSON_OBJECTS) { if (obj.hasOwnProperty('toJSON')) { toJSON_originals.push({ obj, func: obj.toJSON }); delete obj.toJSON; } } return toJSON_originals; } function restore_toJSONs(toJSON_originals) { for (const { obj, func } of toJSON_originals) obj.toJSON = func; } function JSON_stringify(obj, replacer, space) { let toJSON_originals; try { toJSON_originals = delete_toJSONs(); return JSON.stringify(obj, replacer, space); } finally { restore_toJSONs(toJSON_originals); } } // enables JSON.stringify to stringify Sets function set_replacer(key, value) { if (typeof value === 'object' && value instanceof Set) return { t: 'Set', v: [...value] }; return value; } // enables JSON.parse to parse Sets function set_reviver(key, value) { if (typeof value === 'object' && value.t === 'Set') return new Set(value.v); return value; } /***************************/ /* configuration functions */ /***************************/ const IS_IDOL = (window.location.hostname === 'idol.sankakucomplex.com' ? 1 : 0); const HISTORY_KEY = (IS_IDOL ? 'view_history_idol' : 'view_history'); const COMMON_TAGS_KEYS = ['common_tags_json', 'common_tags_json_idol']; const COMMON_TAGS_KEY = COMMON_TAGS_KEYS[IS_IDOL]; const OTHER_COMMON_TAGS_KEY = COMMON_TAGS_KEYS[1 - IS_IDOL]; const DEFAULT_CONFIG = { scroll_to_image: true, scale_image: true, // and video scale_only_downscale: false, scale_flash: false, scale_mode: 0, scale_on_resize: false, scroll_to_image_center: true, video_pause: false, video_mute: true, set_video_volume: false, video_volume: 50, video_controls: true, show_speaker_icon: true, show_animated_icon: true, setparent_deletepotentialduplicate: false, editform_deleteuselesstags: false, hide_headerlogo: false, tag_search_buttons: true, or_tag_search_button: false, tag_menu: true, tag_menu_scale: '30%', tag_menu_layout: 1, common_tags_json: DEFAULT_TAGLIST, common_tags_json_idol: '[ {"name":"test tags", "tags":["tag1 tag2", ["grouped_tag1 grouped_tag2"], "tag3 tag4"] }, { "tags":[ "next_line tag5 tag6", ["grouped_tag3 grouped_tag4"] , "tag7 tag8"] }, {"name":"another\u00A0category", "tags":["t1 t2 t3"]} ]', sankaku_channel_dark_compatibility: false, view_history_enabled: false, view_history: new Set(), view_history_idol: new Set(), wiki_template: '', tag_category_collapser: false, collapsed_tag_categories: new Set(), add_filetype_stat: true, move_stats_to_edit_form: false, }; const USE_MONKEY_STORAGE = IS_MONKEY; let USE_LOCAL_STORAGE; try { USE_LOCAL_STORAGE = !!localStorage.getItem; } catch (error) { // DOMException USE_LOCAL_STORAGE = false; } const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids const config = Object_clone(DEFAULT_CONFIG); // load default // applied to loaded/set config entries (e.g. config elements return strings when we need numbers) const CONFIG_FIXER = { scale_mode: Number, tag_menu_layout: Number, }; function fix_config_entry(key, value) { const fixer = CONFIG_FIXER[key]; return (fixer !== undefined ? fixer(value) : value); } // permanently save setting to localStorage (and broadcast to other tabs) function save_setting(key, value) { value = fix_config_entry(key, value); if (USE_MONKEY_STORAGE) { GM.setValue(key, JSON_stringify(value, set_replacer)).catch((reason) => { show_notice(console.error, `addon error: couldn't save setting "${key}", check console`, reason); }); return; } if (!USE_LOCAL_STORAGE) { show_notice(console.warn, `addon: couldn't save setting "${KEY_PREFIX + key}" to localStorage. check permissions`); return; } try { localStorage.setItem(KEY_PREFIX + key, JSON_stringify(value, set_replacer)); } catch (error) { show_notice(console.error, `addon error: couldn't save setting "${KEY_PREFIX + key}" to localStorage, check console`, error); } } async function load_config() { const monkey_values = {}; if (USE_MONKEY_STORAGE) { const promises = []; for (const key of Object.keys(config)) { promises.push(GM.getValue(key).then((value) => { monkey_values[key] = value; })); } await Promise.all(promises); } for (const key of Object.keys(config)) { let value = config[key]; // default already loaded let stored_value = monkey_values[key]; if (stored_value === undefined && USE_LOCAL_STORAGE) stored_value = localStorage.getItem(KEY_PREFIX + key); if (stored_value !== undefined && stored_value !== null) { try { value = JSON.parse(stored_value, set_reviver); } catch (error) { show_notice(console.error, `addon error: couldn't load setting "${key}"`, error); } } update_setting(key, value); // fire regardless } } function storage_changed(key, old_value, new_value, remote) { try { if (!remote) return; // only listen to other tabs if (new_value === undefined || new_value === null) { // entry was removed, reset setting to default update_setting(key, Object_clone(DEFAULT_CONFIG[key])); } else { // entry was added or changed const value = JSON.parse(new_value, set_reviver); // workaround for post view history race condition if (key === HISTORY_KEY) { const new_ids = Set_difference(value, config[key]); if (new_ids.size === 0) return; // integrate newly received post ids into view history config[key] = Set_union(value, config[key]); // save new view history and broadcast it to other tabs, // which in turn might broadcast their ids back to us save_setting(key, value); // live update thumbnails if (!is_personal_post_page()) { for (const id of new_ids) { const thumbs = thumbnail_cache.get(id); if (thumbs === undefined) continue; for (const thumb of thumbs) fadeout_post(thumb); } } return; // don't call update_setting() } update_setting(key, value); } } catch (error) { show_notice(console.error, 'storage_changed() failed, check console', error); } } // localStorage from other tabs changed function local_storage_changed(e) { if (e.storageArea !== localStorage) return; if (e.key === null) return; // ignore external localStorage.clear() for now // only look at SankakuAddon specific changes if (!e.key.startsWith(KEY_PREFIX)) return; const key = e.key.substring(KEY_PREFIX.length); storage_changed(key, e.oldValue, e.newValue, true); } function update_setting(key, value) { config[key] = fix_config_entry(key, value); if (key === 'scale_on_resize') { if (value) add_scale_on_resize_listener(); else remove_scale_on_resize_listener(); } update_config_dialog_by_key(key); if (key === 'hide_headerlogo') { update_headerlogo(); } 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 (USE_MONKEY_STORAGE) GM.deleteValue(key); if (USE_LOCAL_STORAGE) localStorage.removeItem(KEY_PREFIX + key); // also delete if USE_MONKEY_STORAGE update_setting(key, Object_clone(DEFAULT_CONFIG[key])); } function reset_config() { for (const key of Object.keys(config)) { // don't clear the history so the clear history button makes more sense if (key === 'view_history' || key === 'view_history_idol') continue; // don't reset the common tags list of the other site if (key === OTHER_COMMON_TAGS_KEY) continue; reset_setting(key); } } // templates for the config dialog const CONFIG_TABS_TEMPLATE = { general: { name: 'General', categories: ['post', 'general'], }, editing: { name: 'Editing', categories: ['editing'], }, }; 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', 'video_pause', 'video_mute', 'set_video_volume', 'video_controls', ], }, general: { name: 'General', entries: [ 'tag_search_buttons', 'or_tag_search_button', 'tag_category_collapser', 'show_speaker_icon', 'show_animated_icon', 'view_history_enabled', 'hide_headerlogo', 'sankaku_channel_dark_compatibility', ], }, editing: { name: 'Editing', entries: [ 'add_filetype_stat', 'move_stats_to_edit_form', 'setparent_deletepotentialduplicate', 'editform_deleteuselesstags', 'tag_menu', COMMON_TAGS_KEY, 'tag_menu_layout', 'wiki_template', ], }, }; 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'}}, video_pause: {type: 'checkbox', desc: 'Pause (non-flash) videos*'}, video_mute: {type: 'checkbox', desc: 'Mute (non-flash) videos*'}, set_video_volume: {type: 'checkbox', desc: 'Set (non-flash) video volume to: '}, video_controls: {type: 'checkbox', desc: 'Show video controls*'}, tag_search_buttons: {type: 'checkbox', desc: 'Enable + - tag search buttons*'}, or_tag_search_button: {type: 'checkbox', desc: 'Also add ~ tag search button*'}, show_speaker_icon: {type: 'checkbox', desc: 'Show 🔊 icon on thumbnail if it has audio*'}, show_animated_icon: {type: 'checkbox', desc: 'Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*'}, view_history_enabled: {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'}, setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'}, editform_deleteuselesstags: {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag (if there have been changes)'}, tag_category_collapser: {type: 'checkbox', desc: 'Enable tag category collapsers on post pages*'}, hide_headerlogo: {type: 'checkbox', desc: 'Hide header logo'}, sankaku_channel_dark_compatibility: {type: 'checkbox', desc: 'Galinoa\'s Sankaku Channel Dark compatibilty*'}, tag_menu: {type: 'checkbox', desc: 'Activate tag menu*'}, [COMMON_TAGS_KEY]: {type: 'text', desc: 'Common tags list (JSON format):'}, tag_menu_layout: {type: 'select', desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}}, wiki_template: {type: 'text', desc: 'Wiki template:', title: 'Text that will be be shown in a separate textarea on wiki add/edit pages so it can easily be copied'}, add_filetype_stat: {type: 'checkbox', desc: 'Add file type to post "Details"*'}, move_stats_to_edit_form: {type: 'checkbox', desc: 'Move post "Details" to the right of the edit form*'}, }; // whether a config element's value are accessed via '.value' (or otherwise '.checked') function is_value_element(key) { if (key === 'video_volume') return true; // "video_volume" is hardcoded in add_config_dialog() if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type const type = SETTINGS_TEMPLATE[key].type; return (type === 'select' || type === 'text'); } // calls f(cfg_elem, key, get_value) for each existing config element function foreach_config_element(f) { for (const key of Object.keys(config)) { const cfg_elem = document.getElementById(KEY_PREFIX + key); if (cfg_elem === null) continue; if (is_value_element(key)) f(cfg_elem, key, () => cfg_elem.value); else f(cfg_elem, key, () => cfg_elem.checked); } } function update_config_dialog_by_key(key) { const cfg_elem = document.getElementById(KEY_PREFIX + key); if (cfg_elem !== null) { if (is_value_element(key)) cfg_elem.value = config[key]; else cfg_elem.checked = config[key]; } } function update_config_dialog() { for (const key of Object.keys(config)) update_config_dialog_by_key(key); } function update_headerlogo() { hide_headerlogo(config.hide_headerlogo); } function show_config_dialog(bool) { document.getElementById('cfg_dialog').style.display = (bool ? 'block' : 'none'); } /********************/ /* helper functions */ /********************/ function set_cookie(name, value, valid_for_days = 365) { const date = new Date(); date.setTime(date.getTime() + (valid_for_days * 24 * 60 * 60 * 1000)); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${date.toUTCString()}; path=/`; } function get_cookie(name) { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const kv = cookie.split('='); if (kv.length === 2 && kv[0].trim() === name) { return decodeURIComponent(kv[1]); } } return ''; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // helper function to modify nodes on creation function register_observer(node_predicate, node_modifier) { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node_predicate(node)) { if (node_modifier(node, observer)) { // are we done? observer.disconnect(); return; } } } } }); observer.observe(document, { childList: true, subtree: true }); return observer; } function get_scrollbar_width() { 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 Arrays and Sets if (obj instanceof Array) return [...obj]; if (obj instanceof Set) return new Set(obj); const new_obj = {}; for (const [key, value] of Object.entries(obj)) new_obj[key] = Object_clone(value); return new_obj; } return obj; } function Set_difference(a, b) { return new Set([...a].filter((x) => !b.has(x))); } function Set_union(a, b) { return new Set([...a, ...b]); } function insert_node_after(node, ref_node) { ref_node.parentNode.insertBefore(node, ref_node.nextSibling); } function get_resolution(obj) { // natural size only for images, can be 0 when not yet loaded 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) { if (unsafeWindow.notice) unsafeWindow.notice(msg[0]); if (logFunc) logFunc(...msg); } function get_original_background_color() { // the background-color style gets changed through the (site)script, but we need the original one // there has to be a better way than this, right? const current = window.getComputedStyle(document.body).getPropertyValue('background-color'); document.body.style.backgroundColor = ''; const original = window.getComputedStyle(document.body).getPropertyValue('background-color'); document.body.style.backgroundColor = current; return original; } // "rgb(r,g,b)" -> [int(r), int(g), int(b)] function rgb_to_array(rgb) { const arr = rgb.substring(rgb.indexOf('(') + 1, rgb.lastIndexOf(')')).split(/,\s*/); for (let i = 0; i < arr.length; i++) arr[i] = parseInt(arr[i], 10); return arr; } function rgb_array_is_dark(rgb_array) { let avg = 0; for (let i = 0; i < rgb_array.length; i++) avg += rgb_array[i]; avg /= rgb_array.length; return (avg <= 128); } function rgb_array_shift(rgb, shift) { const shifted = []; for (let i = 0; i < 3; i++) shifted.push(Math.min(Math.max(rgb[i] + shift, 0), 255)); return shifted; } // [r, g, b] -> "rgb(r,g,b)" function rgb_array_to_rgb(rgb) { if (rgb.length === 3) return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')'; return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ')'; } function is_darkmode() { const theme = get_cookie('theme'); if (theme !== '' && Number(theme) !== 0) return true; // fallback const rgb = rgb_to_array(get_original_background_color()); return rgb_array_is_dark(rgb); } // helper function to adjust background colors based on light or dark mode function shifted_backgroundColor(shift) { const rgb = rgb_to_array(get_original_background_color()); const shifted_rgb = rgb_array_shift(rgb, (is_darkmode() ? 1 : -1) * shift); return rgb_array_to_rgb(shifted_rgb); } /**************************/ /* general site functions */ /**************************/ const thumbnail_cache = new Map(); // id -> array of thumbnail elements let cached_search_tags = null; function get_search_tags() { if (cached_search_tags !== null) return cached_search_tags; const tags = new URL(window.location.href).searchParams.get('tags'); cached_search_tags = (tags === null ? [] : tags.split(' ')); return cached_search_tags; } // is own uploads or favorites page function is_personal_post_page() { const username = get_username(); if (username === null) return false; const tags = get_search_tags(); return tags.includes('fav:' + username) || tags.includes('user:' + username); } function hide_headerlogo(hide) { const headerlogo = document.getElementById('headerlogo'); if (headerlogo !== null) headerlogo.style.display = (hide ? 'none' : ''); } function add_config_dialog() { const cfg_dialog = document.createElement('DIV'); cfg_dialog.id = 'cfg_dialog'; cfg_dialog.style.display = 'none'; cfg_dialog.style.padding = '6px 12px 6px 12px'; cfg_dialog.style.border = '1px solid ' + shifted_backgroundColor(32); cfg_dialog.style.backgroundColor = get_original_background_color(); // fixed, centered div cfg_dialog.style.top = '50%'; cfg_dialog.style.left = '50%'; cfg_dialog.style.transform = 'translate(-50%, -50%)'; cfg_dialog.style.position = 'fixed'; cfg_dialog.style.zIndex = '10002'; // scroll bars if too large (resizing textareas behaves a bit weirdly on Chrome because it sets margins) cfg_dialog.style.minWidth = '30vw'; cfg_dialog.style.maxWidth = '90vw'; cfg_dialog.style.maxHeight = '90vh'; cfg_dialog.style.overflow = 'auto'; // generate the content of the config menu let innerDivHTML = `<div style='font-weight: bold; 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]; 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(); // hardcode 'video_volume' element: innerDivHTML += (key === 'set_video_volume' ? `<input id="${KEY_PREFIX}video_volume" type="number" min="0" max="100" size="4">%` : ''); break; case 'select': innerDivHTML += generate_span(); innerDivHTML += `<select id="${KEY_PREFIX}${key}">`; for (const [k, v] of Object.entries(value.options)) innerDivHTML += `<option value="${k}">${v}</option>`; innerDivHTML += '</select>'; break; case 'text': innerDivHTML += generate_span(); innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" rows=8 style='width: 100%; box-sizing: border-box'></textarea>`; 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;">Close</button>'; innerDivHTML += '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>'; innerDivHTML += '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>'; innerDivHTML += '</div>'; innerDivHTML += '<div style="padding: 2px"> *requires a page reload.</div>'; cfg_dialog.innerHTML = innerDivHTML; document.body.appendChild(cfg_dialog); // add events document.getElementById('config_close').onclick = () => { show_config_dialog(false); return false; }; document.getElementById('config_reset').onclick = () => { reset_config(); return false; }; document.getElementById('history_clear').onclick = () => { reset_setting(HISTORY_KEY); return false; }; // TODO: generify document.getElementById('cfg_body_editing').style.display = 'none'; document.getElementById('cfg_tab_general').onclick = () => { document.getElementById('cfg_body_post').style.display = 'block'; document.getElementById('cfg_body_general').style.display = 'block'; document.getElementById('cfg_body_editing').style.display = 'none'; return false; }; document.getElementById('cfg_tab_editing').onclick = () => { document.getElementById('cfg_body_post').style.display = 'none'; document.getElementById('cfg_body_general').style.display = 'none'; document.getElementById('cfg_body_editing').style.display = 'block'; return false; }; foreach_config_element((cfg_elem, key, get_value) => { cfg_elem.addEventListener('change', () => { update_setting(key, get_value()); save_setting(key, get_value()); }); }); } function add_config_button() { const navbar = document.getElementById('navbar'); if (navbar === null) return; navbar.style.whiteSpace = 'nowrap'; // hack to fit config button const lang_select = navbar.querySelector('.lang-select'); if (lang_select !== null) lang_select.style.borderRight = 0; // prevent config button from jumping a pixel on mouseover const a = document.createElement('A'); a.href = '#'; a.onclick = () => { show_config_dialog(true); return false; }; a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config'; a.style.fontSize = '120%'; const li = document.createElement('LI'); li.className = 'lang-select'; // match style of top bar li.style.paddingRight = '10px'; li.appendChild(a); navbar.appendChild(li); } function add_tag_search_buttons() { const tagsidebar = document.getElementById('tag-sidebar'); if (tagsidebar === null) return; const items = tagsidebar.getElementsByTagName('LI'); for (const item of items) { const taglink = item.getElementsByTagName('A')[0]; const tagname = taglink.innerText.replace(/ /g, '_'); // hopefully this is the only edgecase // generates click listeners const tag_search_button_func = function(tagname) { return function() { const search_field = document.getElementById('tags'); const search_tags = search_field.value.trim().split(/\s+/); const tag_index = search_tags.indexOf(tagname); // add tag if missing, remove if existing if (tag_index === -1) { search_field.value = search_field.value.trim() + ' ' + tagname; } else { search_tags.splice(tag_index, 1); search_field.value = search_tags.join(' '); } search_field.focus({ preventScroll: true }); return false; }; }; { const a = document.createElement('A'); a.href = '#'; a.innerText = '+'; a.onclick = tag_search_button_func(tagname); item.insertBefore(a, taglink); item.insertBefore(document.createTextNode(' '), taglink); } { const a = document.createElement('A'); a.href = '#'; a.innerText = '-'; a.onclick = tag_search_button_func('-' + tagname); item.insertBefore(a, taglink); item.insertBefore(document.createTextNode(' '), taglink); } if (config.or_tag_search_button) { const a = document.createElement('A'); a.href = '#'; a.innerText = '~'; a.onclick = tag_search_button_func('~' + tagname); item.insertBefore(a, taglink); item.insertBefore(document.createTextNode(' '), taglink); } } } const collapser_map = new Map(); // category -> [collapser, tags] 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]; 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'; for (const tag of tags) tag.style.display = 'none'; } 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'; for (const tag of tags) tag.style.display = ''; } if (collapse) config.collapsed_tag_categories.add(category); else config.collapsed_tag_categories.delete(category); if (save) save_setting('collapsed_tag_categories', config.collapsed_tag_categories); } let drag_collapse = false; let drag_collapse_categories; function drag_collapse_down(e) { e.preventDefault(); drag_collapse = true; const category = e.currentTarget.className; drag_collapse_categories = !config.collapsed_tag_categories.has(category); collapse_tag_category(category, drag_collapse_categories); } function drag_collapse_move(e) { if (!drag_collapse) return; const category = e.currentTarget.className; if (drag_collapse_categories !== config.collapsed_tag_categories.has(category)) collapse_tag_category(category, drag_collapse_categories); } function drag_collapse_up() { drag_collapse = false; } function add_tag_category_collapser() { const tagsidebar = document.getElementById('tag-sidebar'); if (tagsidebar === null) return; const items = tagsidebar.getElementsByTagName('LI'); window.addEventListener('mouseup', drag_collapse_up); const setup_collapser = (collapser, category, tags) => { collapser_map.set(category, [collapser, tags]); collapser.addEventListener('mousedown', drag_collapse_down); collapser.addEventListener('mousemove', drag_collapse_move); for (const tag of tags) tag.addEventListener('mousemove', drag_collapse_move); }; let curr_category = null; let curr_category_tags = []; let prev_category_collapser = null; const categories = []; for (const item of items) { if (item.className === curr_category) { curr_category_tags.push(item); } else { // reached new category if (prev_category_collapser !== null) setup_collapser(prev_category_collapser, curr_category, [...curr_category_tags]); curr_category = item.className; curr_category_tags = []; // item will be pushed in the next iteration, see warning below categories.push(curr_category); // collapser visuals const a = document.createElement('A'); a.style.display = 'flex'; a.style.justifyContent = 'center'; a.href = '#'; a.addEventListener('click', (e) => e.preventDefault()); a.innerHTML = '<div style="width:40%; height: 0; border-top-width: 1px; border-bottom-width: 1px; margin-top: 5px; margin-bottom: 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; margin-top: 5px; margin-bottom: 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 modify_thumbnails(root) { if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return; if (root === null) return; for (const thumb of root.getElementsByClassName('thumb')) { // read thumbnail/post id const pid = thumb.id; if (typeof pid !== 'string' || !pid.startsWith('p')) { show_notice(console.error, 'addon error: thumbnail doesn\'t have an id?!', thumb); return; } const id = Number(pid.substring(1)); if (Number.isNaN(id)) { show_notice(console.error, 'addon error: thumbnail id isn\'t a number?!', id); return; } // use and update thumbnail_cache let thumbs = thumbnail_cache.get(id); if (thumbs === undefined) thumbs = []; const is_new = !thumbs.includes(thumb); if (is_new) thumbs.push(thumb); thumbnail_cache.set(id, thumbs); if (is_new) { add_speaker_icon(thumb, id); if (!is_personal_post_page()) fadeout_viewed_post(thumb, id); } } } function add_speaker_icon(thumb_span) { const img = thumb_span.querySelector('.preview'); if (img === null) return; const a = thumb_span.getElementsByTagName('A'); if (a.length === 0) return; const icon = document.createElement('SPAN'); const tags = img.title.trim().split(/\s+/); if (config.show_speaker_icon && (tags.includes('has_audio'))) { icon.innerText = '🔊'; } else if (config.show_animated_icon && (tags.includes('animated') || tags.includes('video'))) { icon.innerText = '⏩'; } else { return; } icon.className = 'speaker_icon'; icon.style.color = '#666'; icon.style.fontSize = '200%'; icon.style.textShadow = '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white'; // copy img.preview style onto a[0] a[0].style.position = 'absolute'; a[0].style.top = '0'; a[0].style.bottom = '0'; a[0].style.left = '0'; a[0].style.right = '0'; a[0].style.textAlign = 'center'; a[0].style.margin = 'auto'; // makes the element fit the image a[0].style.blockSize = 'fit-content'; a[0].style.inlineSize = 'fit-content'; // position icon on the top right of a[0] / the image thumb_span.style.position = 'relative'; img.style.position = 'static'; icon.style.position = 'absolute'; icon.style.top = '2px'; // account for border icon.style.right = '2px'; icon.style.transform = 'translateX(50%) translateY(-50%)'; thumb_span.style.height = '175px'; // TODO this will be auto for the popular-previews otherwise, which works in Chrome but breaks in Firefox a[0].appendChild(icon); } // TODO this isn't optimal when the thumbnail has low contrast to the background function fadeout_post(thumb_span) { const links = thumb_span.getElementsByTagName('A'); const images = thumb_span.getElementsByTagName('IMG'); if (links.length !== 1 || images.length !== 1) { show_notice(console.error, 'addon error: found abnormal thumbnail?!', thumb_span); return; } // move box shadow from image to link, so opacity doesn't affect it // TODO figure out a way that doesn't break in Chrome //links[0].style.display = 'inline-block'; //links[0].style.boxShadow = window.getComputedStyle(images[0]).getPropertyValue('box-shadow'); //images[0].style.boxShadow = ''; images[0].style.opacity = '20%'; for (const speaker_icon of thumb_span.getElementsByClassName('speaker_icon')) speaker_icon.style.opacity = '20%'; } function fadeout_viewed_post(thumb_span, id) { if (config[HISTORY_KEY].has(id)) fadeout_post(thumb_span); } function add_thumbnail_observer(predicate) { // don't hog CPU when disabled, but requires page reload to activate if (!(config.show_speaker_icon || config.show_animated_icon || config.view_history_enabled)) return; // this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded register_observer(predicate, (node, observer) => { modify_thumbnails(node); return false; // listen forever }); } function configure_video(node) { if (node === null || node.nodeType !== Node.ELEMENT_NODE || node.tagName !== 'VIDEO') return; if (config.video_pause) node.pause(); if (config.set_video_volume) node.volume = config.video_volume / 100.0; if (config.video_mute) node.muted = true; node.controls = config.video_controls; } /***********************************************/ /* main page / visually similar page functions */ /***********************************************/ let called_add_mode_options = false; let call_postmodemenu_init_workaround = false; let cached_username = null; // won't work on any page function get_username() { if (cached_username !== null) return cached_username; // read from the 'My Favorites' button (in one of the subnavs) in the navbar const navbar = document.getElementById('navbar'); if (navbar === null) return null; const fav_prefix = window.location.origin + '/?tags=fav%3A'; for (const a of navbar.getElementsByTagName('A')) { if (typeof a.href === 'string' && a.href.startsWith(fav_prefix)) { cached_username = a.href.substring(fav_prefix.length); break; } } return cached_username; } // this won't be called when not logged in or when the user does not have tag script permission function add_mode_options() { if (called_add_mode_options) return; called_add_mode_options = true; const mode_dropdown = document.getElementById('mode'); if (mode_dropdown === null) { show_notice(console.log, 'addon error: add_mode_options() couldn\'t find mode dropdown?!'); return; } // override change event mode_dropdown.removeAttribute('onchange'); mode_dropdown.onchange = PostModeMenu_change_override; if (!IS_GREASEMONKEY4) { if (mode_dropdown.options.namedItem('choose-parent') === null) { const option = document.createElement('option'); option.text = 'Choose Parent'; option.value = 'choose-parent'; mode_dropdown.add(option); } if (mode_dropdown.options.namedItem('set-parent') === null) { const option = document.createElement('option'); option.text = 'Set Parent'; option.value = 'set-parent'; mode_dropdown.add(option); } } // add_mode_options() was called late if (call_postmodemenu_init_workaround) { PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page } } function add_postmode_hotkeys() { document.addEventListener('keydown', (e) => { const mode_dropdown = document.getElementById('mode'); if (mode_dropdown === null) return; if (e.ctrlKey || e.altKey || e.shiftKey) return; if (e.target === mode_dropdown) { e.preventDefault(); // e.g. 'v' would otherwise change to 'View Posts' } else { const tag = e.target.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') { return; } } const old_mode = mode_dropdown.value; switch (e.key) { case 'v': if (IS_GREASEMONKEY4) { show_notice(console.warn, 'addon: \'Set Parent\' not yet supported in Greasemonkey'); return; } mode_dropdown.value = 'set-parent'; break; case 'c': if (IS_GREASEMONKEY4) { show_notice(console.warn, 'addon: \'Choose Parent\' not yet supported in Greasemonkey'); return; } mode_dropdown.value = 'choose-parent'; break; case 'q': mode_dropdown.value = 'rating-q'; break; case 's': mode_dropdown.value = 'rating-s'; break; case 'e': mode_dropdown.value = 'rating-e'; break; default: return; } // couldn't set mode (option doesn't exist) if (!mode_dropdown.value) mode_dropdown.value = old_mode; PostModeMenu_change_override(); }, true); } function PostModeMenu_init_workaround() { const mode_dropdown = document.getElementById('mode'); if (mode_dropdown === null) { show_notice(console.error, 'addon error: PostModeMenu_init_workaround() couldn\'t find mode dropdown?!'); return; } // issue: new post modes can reset on page load if they were added too late // reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then // calls PostModeMenu.change, which sets the cookie to mode_dropdown.value. // so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset // solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added const mode = get_cookie('addon_mode'); if (mode !== '') { set_cookie('mode', mode, 7); mode_dropdown.value = mode; // setting mode failed, possible when changing to account with lower permissions if (!mode_dropdown.value) { show_notice(console.error, `addon error: couldn't set mode to ${mode}, resetting to 'view'`); mode_dropdown.value = 'view'; set_cookie('mode', 'view', 7); } } PostModeMenu_change_override(); } async function PostModeMenu_change_override() { const mode_dropdown = document.getElementById('mode'); if (mode_dropdown === null) { show_notice(console.error, 'addon error: PostModeMenu_change_override() couldn\'t find mode dropdown?!'); return; } if (!mode_dropdown.value) { show_notice(console.error, 'addon error: invalid mode, resetting to \'view\''); mode_dropdown.value = 'view'; set_cookie('mode', 'view', 7); } const s = mode_dropdown.value; // try to guarantee sitescript has loaded TODO: future note: potential infinite loop while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined) await sleep(100); unsafeWindow.PostModeMenu.change(); set_cookie('addon_mode', s, 7); // set 'backup' cookie const darkmode = is_darkmode(); if (s === 'add-fav') { // FFFFAA, original. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#505000' : '#FFA'); } else if (s === 'remove-fav') { // FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#504000' : '#FEA'); } else if (s === 'apply-tag-script') { // AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF'); } else if (s === 'approve') { // 2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: TODO document.body.style.backgroundColor = '#FDF'; } else if (s === 'choose-parent') { document.body.style.backgroundColor = (darkmode ? '#464600' : '#FFD'); } else if (s === 'set-parent') { if (get_cookie('chosen-parent') === '') { show_notice(console.warn, 'addon: Choose parent first!'); mode_dropdown.value = 'choose-parent'; PostModeMenu_change_override(); } else { document.body.style.backgroundColor = (darkmode ? '#005050' : '#DFF'); } } } let PostModeMenu_click_original = null; function PostModeMenu_click_override(post_id) { if (PostModeMenu_click_original(post_id)) return true; // view mode, let it click const mode_dropdown = document.getElementById('mode'); if (!called_add_mode_options) return false; // not logged in or no tag script permission const s = mode_dropdown.value; if (s === 'choose-parent') { set_cookie('chosen-parent', post_id); mode_dropdown.value = 'set-parent'; PostModeMenu_change_override(); } else if (s === 'set-parent') { const parent_id = get_cookie('chosen-parent'); unsafeWindow.TagScript.run(post_id, 'parent:' + parent_id + (config.setparent_deletepotentialduplicate ? ' -potential_duplicate' : '')); } return false; } /***********************/ /* post page functions */ /***********************/ let post_parent_id = null; // input elem // original post/parent ids let post_id = null; let parent_id = null; let image_data = null; let resize_timer; let tag_update_timer; let tags_changed = false; // set by find_actions_list(): let found_delete_action = false; let mouse_moved = false; // for tag_menu_scaler function tag_menu_scaler_mousedown(e) { e.preventDefault(); mouse_moved = false; window.addEventListener('mousemove', tag_menu_scaler_mousemove); window.addEventListener('mouseup', tag_menu_scaler_mouseup); } function tag_menu_scaler_mousemove(e) { e.preventDefault(); mouse_moved = true; set_tag_menu_scale(e, false); } function tag_menu_scaler_mouseup(e) { e.preventDefault(); if (mouse_moved) set_tag_menu_scale(e, true); window.removeEventListener('mousemove', tag_menu_scaler_mousemove); window.removeEventListener('mouseup', tag_menu_scaler_mouseup); } function set_tag_menu_scale(e, save) { const tag_menu = document.getElementById('tag_menu'); if (tag_menu === null) return; const yFromBottom = window.innerHeight - e.clientY; let yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight)); yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + '%'; tag_menu.style.height = yPercentfromBottom; if (save) save_setting('tag_menu_scale', yPercentfromBottom); } function add_tag_menu() { if (document.getElementById('post_tags') === null) return; // not logged in const tag_menu = document.createElement('DIV'); tag_menu.id = 'tag_menu'; tag_menu.style.display = 'none'; tag_menu.style.width = '100%'; tag_menu.style.height = config.tag_menu_scale; tag_menu.style.position = 'fixed'; tag_menu.style.bottom = '0'; tag_menu.style.overflow = 'auto'; tag_menu.style.backgroundColor = get_original_background_color(); tag_menu.style.zIndex = '10001'; document.body.appendChild(tag_menu); // the inner div ensures tag_menu_close button doesn't scroll with the content tag_menu.innerHTML = '<div style="width: calc(100% - 2px); height: 100%; overflow: auto;"><span id="common_tags"></span>current tags:<span id="current_tags"></span></div>'; const tag_menu_scaler = document.createElement('DIV'); tag_menu_scaler.id = 'tag_menu_scaler'; tag_menu_scaler.style.width = '100%'; tag_menu_scaler.style.height = '6px'; tag_menu_scaler.style.backgroundColor = shifted_backgroundColor(32); tag_menu_scaler.style.position = 'absolute'; tag_menu_scaler.style.top = '0'; tag_menu_scaler.style.cursor = 'ns-resize'; tag_menu_scaler.style.zIndex = '10000'; tag_menu_scaler.addEventListener('mousedown', tag_menu_scaler_mousedown); tag_menu.appendChild(tag_menu_scaler); tag_menu.style.paddingTop = tag_menu_scaler.style.height; // since tag_menu_scaler floats above the tags const create_tag_menu_button = function(id, text) { const button = document.createElement('DIV'); button.id = id; button.style.border = '1px solid ' + shifted_backgroundColor(32); button.style.width = '24px'; button.style.height = '24px'; button.style.position = 'absolute'; button.style.textAlign = 'center'; button.style.cursor = 'pointer'; button.style.backgroundColor = shifted_backgroundColor(16); button.innerHTML = `<span style="width: 100%; display: block; position: absolute; top: 50%; left: 50%; transform: translateX(-50%) translateY(-50%);">${text}</span>`; button.style.zIndex = '10001'; return button; }; const tag_menu_close = create_tag_menu_button('tag_menu_close', 'X'); tag_menu_close.style.top = '0'; tag_menu_close.style.right = '0'; tag_menu_close.onclick = () => { show_tag_menu(false); return false; }; tag_menu.appendChild(tag_menu_close); const tag_menu_open = create_tag_menu_button('tag_menu_open', '«'); tag_menu_open.style.position = 'fixed'; tag_menu_open.style.right = '0'; tag_menu_open.style.bottom = '0'; tag_menu_open.onclick = () => { show_tag_menu(true); update_tag_menu(); return false; }; document.body.appendChild(tag_menu_open); const tag_menu_save = create_tag_menu_button('tag_menu_save', 'Save changes'); tag_menu_save.style.top = '0'; tag_menu_save.style.right = '36px'; tag_menu_save.style.width = '140px'; tag_menu_save.style.fontWeight = 'bold'; tag_menu_save.addEventListener('click', (e) => { e.preventDefault(); if (tags_submit_listener()) document.getElementById('edit-form').submit(); }); tag_menu.appendChild(tag_menu_save); } function update_tag_menu(skip_common_tags = false) { if (document.getElementById('post_tags') === null) return; // not logged in const common_tags_elem = document.getElementById('common_tags'); const current_tags_elem = document.getElementById('current_tags'); // tag menu disabled if (common_tags_elem === null || current_tags_elem === null) return; if (config.tag_menu_layout === 1) { common_tags_elem.style.display = 'grid'; common_tags_elem.style.gridTemplateColumns = 'fit-content(5%) auto'; } else { common_tags_elem.style.display = ''; } const create_tag_button = function(tag, skip_common_tags_update = false) { const a = document.createElement('A'); a.href = '#'; a.style.paddingLeft = '5px'; a.style.paddingRight = '5px'; a.style.borderStyle = 'solid'; a.style.borderWidth = '1px'; a.style.backgroundColor = (is_darkmode() ? '#000' : '#FFF'); // more contrast for tag buttons a.className = (tag_is_present(tag) ? '' : 'tag_nonexistent'); a.onclick = function() { if (tag_is_present(tag)) { remove_tag(tag, skip_common_tags_update); a.className = 'tag_nonexistent'; } else { add_tag(tag, skip_common_tags_update); a.className = ''; } return false; }; a.innerText = tag; return a; }; const wrap_in_div = function(el, margin) { const div = document.createElement('DIV'); div.style.margin = margin; div.style.float = 'left'; div.appendChild(el); return div; }; const create_top_level_div = function(margin = '3px') { const div = document.createElement('DIV'); div.style.margin = margin; return div; }; const create_tag_list = function() { const div = document.createElement('DIV'); div.style.display = 'flex'; div.style.flexWrap = 'wrap'; div.style.alignContent = 'flex-start'; div.style.alignItems = 'flex-start'; div.style.margin = '0'; div.style.padding = '0'; return div; }; // generate tag button list for current tags const current_tags_flex = create_tag_list(); current_tags_flex.style.marginBottom = '3px'; const current_tags = get_tags_array(); for (const current_tag of current_tags) { const div = create_top_level_div(); div.appendChild(create_tag_button(current_tag, false)); current_tags_flex.appendChild(div); } // replace current list with new one while (current_tags_elem.hasChildNodes()) current_tags_elem.removeChild(current_tags_elem.lastChild); current_tags_elem.appendChild(current_tags_flex); // don't rebuild the common tags list when common tags buttons are pressed if (skip_common_tags) return; // now add common tags // common_tags_json(_idol) should be an array of objects with an optional string "name" field and an array "tags" field, // where the "tags" array can contain strings (space separated tags), arrays containing one string (representing a group) // or arrays of array containing one string (representing a table) // ex. [ { "name":"common tags", "tags":[ "tag1 tag2", ["grouped_tag1 grouped_tag2"] , "tag3 tag4"] }, { "name":"uncommon tags", "tags":[ "t1 t2 t3" ]} ] let tag_data; try { tag_data = JSON.parse(config[COMMON_TAGS_KEY]); } catch (error) { show_notice(console.error, 'addon error: "common tags" JSON syntax error', error); return; } if (!Array.isArray(tag_data)) { show_notice(console.error, 'addon error: "common tags" needs to be an array of objects'); return; } while (common_tags_elem.hasChildNodes()) common_tags_elem.removeChild(common_tags_elem.lastChild); for (let k = 0; k < tag_data.length; k++) { const list_flex = create_tag_list(); const list_name = tag_data[k].name; const list_tags = tag_data[k].tags; if (!Array.isArray(list_tags)) { show_notice(console.error, 'addon error: a "common tags" object needs to have a "tags" array'); return; } const TAGS_TYPES = { LIST: 'list', // e.g. "tag1 tag2" GROUP: 'group', // e.g. ["tag1 tag2"] TABLE: 'table' // e.g. [["tag1 tag2"], ["tag3 tag4"]] }; const group_style = function(el) { // red in darkmode needs more contrast const rgb = rgb_to_array(get_original_background_color()); if (is_darkmode()) { el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, 96)); el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, 64)); } else { el.style.border = '1px solid ' + rgb_array_to_rgb(rgb_array_shift(rgb, -64)); el.style.backgroundColor = rgb_array_to_rgb(rgb_array_shift(rgb, -32)); } }; for (const list_tag of list_tags) { const is_array = Array.isArray(list_tag); // find tags_type let tags_type; if (is_array) { if (list_tag.length === 0) { show_notice(console.error, 'addon error: "common tags" "tags" array contains an empty array'); return; } // check what the array consists of let all_arrays = true; let no_arrays = true; for (let i = 0; i < list_tag.length; i++) { if (!Array.isArray(list_tag[i])) { all_arrays = false; } else { no_arrays = false; } } if (all_arrays) { tags_type = TAGS_TYPES.TABLE; } else if (no_arrays) { tags_type = TAGS_TYPES.GROUP; } else { show_notice(console.error, 'addon error: "common tags" "tags" array contains an array which is neither a group nor a table'); return; } } else { tags_type = TAGS_TYPES.LIST; } if (tags_type === TAGS_TYPES.TABLE) { const tags_table = []; for (let j = 0; j < list_tag.length; j++) { if (list_tag[j].length !== 1) { show_notice(console.error, 'addon error: "common tags" "tags" array contains a table entry with not exactly 1 tags string'); return; } tags_table.push(list_tag[j][0].trim().split(/\s+/)); } const table_height = tags_table.length; let table_width = 0; for (let row = 0; row < tags_table.length; row++) table_width = Math.max(table_width, tags_table[row].length); // div (flexbox)><div><table><tr><td><div (button)> const table = document.createElement('TABLE'); table.style.display = 'inline-block'; group_style(table); table.style.marginBottom = '0'; for (let row = 0; row < table_height; row++) { const tr = document.createElement('TR'); for (let col = 0; col < table_width; col++) { const td = document.createElement('TD'); td.style.border = 'none'; td.style.padding = '0'; if (tags_table[row][col]) td.appendChild(wrap_in_div(create_tag_button(tags_table[row][col], true), '1px')); tr.appendChild(td); } table.appendChild(tr); } const div = create_top_level_div('0 3px 0 3px'); div.appendChild(table); list_flex.appendChild(div); } else if (tags_type === TAGS_TYPES.GROUP) { if (list_tag.length !== 1) { show_notice(console.error, 'addon error: "common tags" "tags" array contains a group with not exactly 1 tags string'); return; } const tags = list_tag[0].trim().split(/\s+/); // <div (flexbox)><div><div (button)> const group_div = document.createElement('DIV'); group_div.style.display = 'inline-block'; group_style(group_div); for (const tag of tags) group_div.appendChild(wrap_in_div(create_tag_button(tag, true), '3px')); const div = create_top_level_div('0 3px 0 3px'); div.appendChild(group_div); list_flex.appendChild(div); } else /* if (tags_type === tag_types.LIST) */ { // <div (flexbox)><div><div (button)> const tags = list_tag.trim().split(/\s+/); for (const tag of tags) { const div = create_top_level_div('4px 3px 2px 3px'); div.appendChild(wrap_in_div(create_tag_button(tag, true))); list_flex.appendChild(div); } } } const span = document.createElement('SPAN'); span.innerText = (list_name ? `${list_name}:` : ''); span.style.paddingTop = '2px'; if (list_name) span.style.marginLeft = '2px'; if (list_name && config.tag_menu_layout === 1) { const add_top_border = function(el) { el.style.borderTopWidth = '1px'; el.style.borderTopStyle = 'solid'; el.style.borderTopColor = shifted_backgroundColor(32); }; add_top_border(span); add_top_border(list_flex); } common_tags_elem.appendChild(span); common_tags_elem.appendChild(list_flex); } } function show_tag_menu(bool) { document.getElementById('tag_menu').style.display = (bool ? '' : 'none'); document.getElementById('tag_menu_open').style.display = (!bool ? '' : 'none'); } function add_tags_change_listener() { const post_tags_area = document.getElementById('post_tags'); if (post_tags_area === null) return; // not logged in post_tags_area.addEventListener('input', () => { tags_changed = true; clearTimeout(tag_update_timer); tag_update_timer = setTimeout(update_tag_elements, 500); }); } // also used for 'tag_menu_save' button function tags_submit_listener() { delete_useless_tags_tag(); return true; // actually submit? } function add_tags_submit_listener() { const edit_form = document.getElementById('edit-form'); if (edit_form === null) return; // not logged in edit_form.addEventListener('submit', (e) => { if (!tags_submit_listener()) e.preventDefault(); }); } async function find_actions_list() { // TODO: stop trying at some point (readyState === 'complete'?) let li; while ((li = document.getElementById('add-to-pool')) === null) await sleep(200); let actions_ul; while ((actions_ul = li.parentElement) === null) await sleep(100); const action_links = actions_ul.getElementsByTagName('A'); for (const action_link of action_links) { if (action_link.innerText === 'Delete') { found_delete_action = true; break; } } return actions_ul; } 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(config.scroll_to_image_center); }, 'Fit image', 'scale-image-fit'); add_action(() => { scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor'); add_action(() => { scale_image(SCALE_MODES.VERTICAL, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)', 'scale-image-ver'); add_action(() => { scale_image(SCALE_MODES.RESET, true); scroll_to_image(config.scroll_to_image_center); }, 'Reset image size', 'reset-image'); if (parent_id === null) return; // not logged in if (post_id === null) { show_notice(console.error, 'addon error: couldn\'t find post id?! Flag duplicate feature disabled.'); return; } add_action(() => { flag_duplicate(post_id, ''); }, 'Flag duplicate', 'flag-duplicate'); add_action(() => { flag_duplicate(post_id, ', visually identical'); }, 'Flag duplicate (identical)', 'flag-duplicate-identical'); add_action(() => { flag_duplicate(post_id, ' with worse quality'); }, 'Flag duplicate (quality)', 'flag-duplicate-quality'); add_action(() => { flag_duplicate(post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution'); } function add_filetype_stat() { try { const img_link = document.getElementById('highres'); if (img_link === null) return; // flash? const s = new URL(img_link.href).pathname.split('.'); const ext = s[s.length - 1]; const filetype = document.createTextNode(`File type: ${ext}`); const li = document.createElement('LI'); li.appendChild(filetype); insert_node_after(li, img_link.parentNode); } catch (error) { console.error('addon error: add_filetype_stat failed with', error); } } function move_stats_to_edit_form() { try { // form display: flex // stats insertafter table // stats white-space: nowrap // #edit-form width: max-content // margin-left: 8px; const stats = document.getElementById('stats'); const edit_form = document.getElementById('edit-form'); if (edit_form === null) return; const table = edit_form.getElementsByTagName('TABLE')[0]; 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() { const edit_form = document.getElementById('edit-form'); if (edit_form === null) return; // not logged in const button_place = edit_form.children[1].children[0].children[0].children[0]; button_place.style.whiteSpace = 'nowrap'; // min-width is 25% by default, which makes the <td> not fit its content button_place.children[0].style.minWidth = '13.5em'; { const el = document.createElement('BUTTON'); el.id = 'clear_parent_id_button'; el.style.margin = '0 3px 0 6px'; el.innerText = 'Clear'; el.onclick = () => { post_parent_id.clear(); return false; }; post_parent_id.parentNode.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'reset_parent_id_button'; el.style.margin = '0 3px'; el.innerText = 'Reset'; el.onclick = () => { reset_parent_id(); return false; }; post_parent_id.parentNode.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_reset_button'; el.style.margin = '0 3px 0 6px'; el.innerText = 'Reset'; el.onclick = () => { reset_tags(); return false; }; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_dup_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_var_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_pot_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } } function update_tag_buttons() { const taglist = document.getElementById('post_tags'); const dup_button = document.getElementById('tag_dup_button'); const var_button = document.getElementById('tag_var_button'); const pot_button = document.getElementById('tag_pot_button'); if (taglist === null || dup_button === null || var_button === null || pot_button === null) return; const tags = get_tags_array(); if (!tags.includes('duplicate')) { dup_button.onclick = function() { add_tag('duplicate'); return false; }; dup_button.innerText = 'Tag duplicate'; } else { dup_button.onclick = function() { remove_tag('duplicate'); return false; }; dup_button.innerText = 'Untag duplicate'; } if (!tags.includes('legitimate_variation')) { var_button.onclick = function() { add_tag('legitimate_variation'); return false; }; var_button.innerText = 'Tag legitimate_variation'; } else { var_button.onclick = function() { remove_tag('legitimate_variation'); return false; }; var_button.innerText = 'Untag legitimate_variation'; } pot_button.innerText = 'Untag potential_duplicate'; if (!tags.includes('potential_duplicate')) { pot_button.disabled = true; } else { pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; }; pot_button.disabled = false; } } function reset_parent_id() { post_parent_id.value = parent_id; } function get_old_tags_array() { return document.getElementById('post_old_tags').value.trim().split(/\s+/); } function get_tags_array() { return document.getElementById('post_tags').value.trim().split(/\s+/); } function add_tag(tag, skip_common_tags_update = false) { const tags = get_tags_array(); if ((tag === 'duplicate' && tags.includes('legitimate_variation')) || (tag === 'legitimate_variation' && tags.includes('duplicate'))) { show_notice(console.warn, 'addon: cannot tag as duplicate and legitimate_variation at the same time.'); return; } if (tags.includes(tag)) { show_notice(console.warn, 'addon: tag already present'); return; } document.getElementById('post_tags').value += ' ' + tag; tags_changed = true; update_tag_elements(skip_common_tags_update); } function remove_tag(tag, skip_common_tags_update = false) { const tags = get_tags_array(); for (let i = 0; i < tags.length; i++) if (tags[i] === tag) tags[i] = ''; document.getElementById('post_tags').value = tags.join(' ').trim(); tags_changed = true; update_tag_elements(skip_common_tags_update); } function delete_useless_tags_tag() { if (tags_changed && config.editform_deleteuselesstags) remove_tag('useless_tags'); } function tag_is_present(tag) { return get_tags_array().includes(tag); } function reset_tags() { document.getElementById('post_tags').value = document.getElementById('post_old_tags').value; tags_changed = false; update_tag_elements(); } function update_tag_elements(skip_common_tags = false) { update_tag_buttons(); const tag_menu = document.getElementById('tag_menu'); if (tag_menu !== null && tag_menu.style.display !== 'none') { update_tag_menu(skip_common_tags); } } // flag option with default text function flag_duplicate(id, reason_suffix) { if (IS_GREASEMONKEY4) { show_notice(console.warn, 'addon: \'Flag duplicate\' not yet supported in Greasemonkey'); return; } if (parent_id === null) { show_notice(console.warn, 'addon: parent id not found, not logged in?'); return; } const current_parent_id = post_parent_id.value; if (current_parent_id !== parent_id) { show_notice(console.warn, 'addon: parent id was changed but not saved!'); return; } if (!current_parent_id || current_parent_id.length === 0) { show_notice(console.warn, 'addon: no parent id set!'); return; } const tags = get_tags_array(); const old_tags = get_old_tags_array(); if (tags.includes('duplicate') && !old_tags.includes('duplicate')) { show_notice(console.warn, 'addon: duplicate tag set but not saved!'); return; } if (!old_tags.includes('duplicate')) { show_notice(console.warn, 'addon: not tagged as duplicate!'); return; } if (old_tags.includes('legitimate_variation') || old_tags.includes('revision')) if (!window.confirm('Post is tagged as a legitimate_variation or revision, it may not be a duplicate!\n\nFlag it anyway?')) return; const reason = window.prompt('Why should this post be reconsidered for moderation?', `duplicate of ${parent_id}${reason_suffix}`); if (reason === null) return; // TODO will not work on Greasemonkey at all new unsafeWindow.Ajax.Request('/post/flag.json', { parameters: { id, reason }, onComplete(response) { const resp = response.responseJSON; if (resp.success) show_notice(console.log, 'Post was resent to moderation queue'); else show_notice(console.error, `Error: ${resp.reason}`); } }); } // 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; // workaround for Galinoa's Sankaku Channel Dark / Stylus: get get_resolution from "Details" let res = null; const lowres = document.getElementById('lowres'); if (lowres !== null) { res = lowres.innerText.split('x'); // parse "<width>x<height>" } else { const highres = document.getElementById('highres'); if (highres !== null) { res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)" } } if (res === null) { console.log('[addon] Couldn\'t read get_resolution from details section, waiting for image size...'); // TODO: should abort when content failed loading while ((res = get_resolution(img)) === null) await sleep(100); } data.width = res[0]; data.height = res[1]; console.log('[addon] Read get_resolution ', data.width, 'x', data.height); data.current_height = data.height; data.aspect_ratio = data.width / data.height; image_data = data; } // flash or unknown const non_img = document.getElementById('non-image-content'); if (non_img !== null) { data.non_img_div = non_img; const objs = non_img.getElementsByTagName('OBJECT'); const embs = non_img.getElementsByTagName('EMBED'); data.is_flash = (objs.length === 1 && embs.length === 1); // <object><embed> if (!data.is_flash) { show_notice('addon error: unknown post content! Can\'t read width/height.'); return null; } data.img_elem = objs[0]; data.emb_elem = embs[0]; // <object> contains width/height in both Firefox and Chrome data.width = data.img_elem.width; data.height = data.img_elem.height; data.current_height = data.height; data.aspect_ratio = data.width / data.height; return data; } return null; } const SCALE_MODES = { RESET: -1, FIT: 0, HORIZONTAL: 1, VERTICAL: 2 }; // stretch image/video/flash, requires data from read_image_data() function scale_image(mode, always_scale) { if (image_data === null) return; // read_image_data() failed if (!always_scale && (!config.scale_flash && image_data.is_flash)) return; const note_container = document.getElementById('note-container'); // reset image size if (mode === SCALE_MODES.RESET) { if (!image_data.is_flash) { image_data.img_elem.style.width = null; image_data.img_elem.style.height = null; if (note_container !== null) note_container.style.transform = ''; } else { image_data.img_elem.width = image_data.width; image_data.img_elem.height = image_data.height; image_data.emb_elem.width = image_data.width; image_data.emb_elem.height = image_data.height; } image_data.current_height = image_data.height; // workaround for Galinoa's Sankaku Channel Dark if (config.sankaku_channel_dark_compatibility) { image_data.img_elem.style.paddingLeft = ''; note_fix(); } return; } // workaround for Galinoa's Sankaku Channel Dark // problem: seems to only work for bigger windows let left_side; if (config.sankaku_channel_dark_compatibility) { const sidebar = document.getElementsByClassName('sidebar')[0]; left_side = (sidebar.getBoundingClientRect().right + window.pageXOffset + 12); image_data.img_elem.style.paddingLeft = left_side + 'px'; // don't hide behind sidebar } else { left_side = image_data.img_elem.getBoundingClientRect().left + window.pageXOffset; } const target_w = Math.max(window.innerWidth - left_side - get_scrollbar_width(), 1); 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); let new_width, new_height; if (mode === SCALE_MODES.HORIZONTAL) { new_width = Math.floor(target_w); new_height = Math.floor(target_w / image_data.aspect_ratio); } else if (mode === SCALE_MODES.VERTICAL) { new_width = Math.floor(target_h * image_data.aspect_ratio); new_height = Math.floor(target_h); } if (!always_scale && (config.scale_only_downscale && (new_width > image_data.width || new_height > image_data.height))) return; // We sadly can't use transform scale here because it doesn't change the DOM size, // so the post content would cover up (or be covered up by) the elements below it const set_dimensions = (obj) => { obj.width = new_width + 'px'; obj.height = new_height + 'px'; }; if (image_data.is_flash) { set_dimensions(image_data.img_elem); set_dimensions(image_data.emb_elem); } else { set_dimensions(image_data.img_elem.style); // For notes we need to use transform scale because style.width/height/top/left // is exactly what gets stored on the server if (note_container !== null) { note_container.style.transformOrigin = '0 0'; note_container.style.transform = `scale(${new_width / image_data.width})`; } } image_data.current_height = new_height; } function scale_on_resize_helper() { clearTimeout(resize_timer); resize_timer = setTimeout(() => { if (config.scale_on_resize) scale_image(config.scale_mode, false); }, 100); } function add_scale_on_resize_listener() { window.addEventListener('resize', scale_on_resize_helper); } function remove_scale_on_resize_listener() { window.removeEventListener('resize', scale_on_resize_helper); } function scroll_to_image(to_center) { window.requestAnimationFrame(() => { window.setTimeout(() => { if (image_data === null) return; const absolute_img_top = (image_data.is_flash ? image_data.non_img_div : image_data.img_elem).getBoundingClientRect().top + window.pageYOffset; if (to_center) { const top_of_centered_rect = absolute_img_top - (window.innerHeight - image_data.current_height) / 2; window.scrollTo(0, top_of_centered_rect); } else { window.scrollTo(0, absolute_img_top); } }, 50); }); } // when resize notice is hidden (e.g. original image is loaded), scroll to make up the difference function add_resize_notice_listener() { const resized_notice = document.getElementById('resized_notice'); if (image_data === null || resized_notice === null) return; const notice_y_diff = image_data.img_elem.getBoundingClientRect().top - resized_notice.getBoundingClientRect().top; const observer = new MutationObserver(() => { observer.disconnect(); window.scrollBy(0, -notice_y_diff); }); observer.observe(resized_notice, { attributes: true, attributeFilter: ['style'], }); } // simple note fix for Galinoa's Sankaku Channel Dark (only for default image size) function note_fix() { const note_container = document.getElementById('note-container'); if (note_container !== null && image_data !== null) { note_container.style.marginLeft = ((window.innerWidth - image_data.img_elem.clientWidth) / 2 - 8) + 'px'; } } function add_postpage_hotkeys() { document.addEventListener('keydown', (e) => { const tag = e.target.tagName.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (e.ctrlKey || e.altKey || e.shiftKey) return; switch (e.key) { case 'r': // r(eset) scale_image(SCALE_MODES.RESET, true); scroll_to_image(config.scroll_to_image_center); break; case 'f': // f(it) scale_image(SCALE_MODES.FIT, true); scroll_to_image(config.scroll_to_image_center); break; case 'g': // g scale_image(SCALE_MODES.HORIZONTAL, true); scroll_to_image(config.scroll_to_image_center); break; case 'h': // h scale_image(SCALE_MODES.VERTICAL, true); scroll_to_image(config.scroll_to_image_center); break; case 's': // s(imilar) if (post_id === null) { show_notice(console.error, 'addon error: couldn\'t find post id?!'); } else { open_in_tab(window.location.origin + '/post/similar?id=' + post_id); } break; case 'd': // d(elete) if (post_id === null) { show_notice(console.error, 'addon error: couldn\'t find post id?!'); } else if (!found_delete_action) { show_notice(console.error, 'addon error: Delete action not found, no permission?'); } else { open_in_tab(window.location.origin + '/post/delete/' + post_id); } break; } }, true); } // 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_pixiv_source_link() { const stats = document.getElementById('stats'); if (stats === null) return; for (const link of stats.getElementsByTagName('A')) { if (link.href && 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) { } } } } /***********************/ /* wiki page functions */ /***********************/ function add_dtext_style_buttons() { const wiki_form = document.getElementById('wiki-form'); const wiki_body = document.getElementById('wiki_page_body'); if (wiki_form === null || wiki_body === null) { show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", DText style buttons disabled'); return; } wiki_form.style.display = 'flex'; // add buttons to the right const div = document.createElement('DIV'); div.style.marginLeft = '1em'; div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top) + 'px'; div.style.background = get_original_background_color(); div.innerHTML = '<label title="Buttons add (or remove outermost) DText styling on selected text.\n' + 'Buttons have accesskeys/keyboard shortcuts, typically activated by Alt + Shift + key or Alt + key depending on the browser."' + ' style="cursor:help;">DText style buttons</label><br>'; const DTEXT_CODES = [ 'b', 'i', 's', 'u', '<br>', 'code', 'quote', 'spoiler', '<br>', '[[...]]', '{{...}}', 'URL', ]; for (const code of DTEXT_CODES) { if (code === '<br>') { div.appendChild(document.createElement('BR')); continue; } const btn = document.createElement('BUTTON'); btn.style.cursor = 'pointer'; btn.onclick = () => { const text = wiki_body.value; const a = wiki_body.selectionStart; const b = wiki_body.selectionEnd; const selection = text.substring(a, b); let cut_a = a, cut_b = b; // range to cut let insert; // text to insert inbetween let sel_off_a = 0, sel_off_b = 0; // by default the inserted text is selected, these are offsets if (code === 'URL') { const link = window.prompt('Enter URL'); if (link === null) return false; insert = `"${selection}":${link}`; } else { let open = ''; let close = ''; if (code === '{{...}}') { open = '{{'; close = '}}'; } else if (code === '[[...]]') { open = '[['; close = ']]'; } else { open = `[${code}]`; close = `[/${code}]`; } const ext_a = Math.max(0, a - open.length); const ext_b = b + close.length; const ext_selection = text.substring(ext_a, ext_b); if (ext_selection.startsWith(open) && ext_selection.endsWith(close)) { // remove DText directly outside selection insert = selection; cut_a = ext_a; cut_b = ext_b; sel_off_a = -open.length; } else if (selection.startsWith(open) && selection.endsWith(close)) { // remove DText directly inside selection insert = selection.slice(open.length, -close.length); } else { let cleaned; const open_i = selection.indexOf(open); const close_i = selection.lastIndexOf(close); // remove existing (inner) DText before adding new one if (open_i !== -1 && close_i > open_i) { cleaned = selection.substring(0, open_i) + selection.substring(open_i + open.length, close_i) + selection.substring(close_i + close.length); } else { cleaned = selection; } insert = open + cleaned + close; // select 'cleaned' sel_off_a = open.length; sel_off_b = -(open.length + close.length); } } const sel_a = a + sel_off_a; const sel_b = sel_a + insert.length + sel_off_b; wiki_body.value = text.substring(0, cut_a) + insert + text.substring(cut_b); wiki_body.setSelectionRange(Math.max(0, sel_a), sel_b); wiki_body.focus(); return false; }; // add accesskeys and titles if (code.length === 1) { btn.accessKey = code; btn.title = code === 'b' ? 'bold' : code === 'i' ? 'italic' : code === 's' ? 'strikethrough' : code === 'u' ? 'underline' : ''; } else { btn.accessKey = code === 'code' ? 'c' : code === 'quote' ? 'q' : code === 'spoiler' ? 'o' : code === '[[...]]' ? 'w' : code === '{{...}}' ? 'p' : code === 'URL' ? 'l' : ''; if (code === '[[...]]') btn.title = 'accesskey: w'; if (code === '{{...}}') btn.title = 'accesskey: p'; } const u = // character to underline code === 'code' ? 0 : code === 'quote' ? 0 : code === 'spoiler' ? 2 : code === 'URL' ? 2 : code.length === 1 ? 0 : -1; if (u !== -1) { btn.innerHTML = `${code.substring(0, u)}<span style="text-decoration: underline;">${code[u]}</span>${code.substring(u + 1)}`; } else { btn.innerText = code; } div.appendChild(btn); } wiki_form.appendChild(div); } function add_wiki_template() { if (config.wiki_template.length === 0) return; const wiki_form = document.getElementById('wiki-form'); const wiki_body = document.getElementById('wiki_page_body'); if (wiki_form === null || wiki_body === null) { show_notice(console.error, 'addon error: couldn\'t find "wiki-form" or "wiki_page_body", wiki template disabled'); return; } wiki_form.style.display = 'flex'; // add template to the right const div = document.createElement('DIV'); div.style.marginLeft = '1em'; const template_label = document.createElement('LABEL'); template_label.innerText = 'Wiki Template'; template_label.style.cursor = 'help'; template_label.style.textDecoration = 'underline dashed'; template_label.title = 'Selected text can be appended to the page body. Clicking or using arrow keys selects a whole line, pressing \'c\' or the button below copies the selection over'; const template_text = document.createElement('TEXTAREA'); template_text.id = 'wiki_template_text'; template_text.cols = wiki_body.cols; template_text.rows = wiki_body.rows; template_text.style.width = '33em'; template_text.value = config.wiki_template; const insert_template_selection = () => { const text = template_text.value; const a = template_text.selectionStart; const b = template_text.selectionEnd; const selection = text.substring(a, b); const add_newline = wiki_body.value && !wiki_body.value.endsWith('\n'); wiki_body.value += (add_newline ? '\n' : '') + selection; }; const extend_selection = () => { // extend empty selection to newlines (or text start/end) const text = template_text.value; let a = template_text.selectionStart; let b = template_text.selectionEnd; if (a === b) { const ext_a = text.lastIndexOf('\n', a - 1); a = (ext_a !== -1 ? ext_a + 1 : 0); if (text.charAt(b) !== '\n') { const ext_b = text.indexOf('\n', b + 1); b = (ext_b !== -1 ? ext_b - 1 : text.length - 1) + 1; } template_text.setSelectionRange(a, b); } }; template_text.readOnly = true; // hides the caret and there's no easy workaround template_text.addEventListener('click', extend_selection); template_text.addEventListener('keyup', extend_selection); template_text.addEventListener('keydown', (e) => { if (e.ctrlKey || e.altKey || e.shiftKey) return; if (e.key === 'c') insert_template_selection(); }); const btn = document.createElement('BUTTON'); btn.innerText = 'Copy selection over'; btn.style.cursor = 'pointer'; btn.style.fontWeight = 'bold'; btn.style.padding = '0.2em 2em'; btn.style.margin = '0.1em'; btn.onclick = () => { insert_template_selection(); template_text.focus(); return false; }; div.appendChild(template_label); div.appendChild(document.createElement('BR')); div.appendChild(template_text); div.appendChild(document.createElement('BR')); div.appendChild(btn); wiki_form.appendChild(div); div.style.marginTop = (wiki_body.getBoundingClientRect().top - wiki_form.getBoundingClientRect().top - template_label.getBoundingClientRect().height) + 'px'; } function add_tag_edit_gear() { try { // add a "⚙" link to the edit tag page const h2 = document.getElementsByClassName('title')[0]; const tag = new URL(window.location.href).searchParams.get('title'); const wiki_edit_link = document.createElement('A'); wiki_edit_link.href = '/tag/edit?name=' + tag; wiki_edit_link.innerText = '⚙'; wiki_edit_link.title = 'Edit Tag'; h2.appendChild(wiki_edit_link); } catch (error) { show_notice(console.error, 'addon error: couldn\'t add "⚙" tag page link, check console', error); } } /******************/ /* document-start */ /******************/ await load_config(); // listen for config changes in other windows if (USE_MONKEY_STORAGE) for (const key of Object.keys(config)) GM.addValueChangeListener(key, storage_changed); else if (USE_LOCAL_STORAGE) window.addEventListener('storage', local_storage_changed); else show_notice(console.error, 'addon error: couldn\'t add storage change listener! No cross-tab communication possible.'); /*************************************/ /* main page / visually similar page */ /*************************************/ // skip language codes in pathnames like "/jp/post/show" let pathname = window.location.pathname; if (pathname.indexOf('/', 1) === 3) pathname = pathname.substring(3); if (pathname === '/' || pathname === '/post/index' || pathname.startsWith('/post/similar')) { // try to add new modes right after the "Apply tag script" mode is added to prevent it being reset to "View posts" on page reloads // it's possible we are too late to observe its construction, so look for it afterwards immediately const observer = register_observer((node) => { return (node.value === 'apply-tag-script'); }, (node, observer) => { observer.disconnect(); // don't pass node.parentNode, because it can be undefined or the <div> above for reasons I cannot understand add_mode_options(); return true; }); const dropdown = document.getElementById('mode'); if (dropdown !== null) { for (const child of dropdown.children) { if (child.value === 'apply-tag-script') { // it's already there observer.disconnect(); // stop looking for it add_mode_options(dropdown); break; } } } // add thumbnail icons and fade out thumbnails for dynamically loaded posts (from auto paging) add_thumbnail_observer((node) => (node.classList != null && node.classList.contains('content-page'))); /*************/ /* post page */ /*************/ } else if (pathname.startsWith('/post/show/')) { // mute/pause videos const observer = register_observer((node) => { return (node.id === 'image'); }, (node, observer) => { configure_video(node); return true; }); const video = document.getElementById('image'); if (video !== null) { observer.disconnect(); configure_video(video); } add_thumbnail_observer((node) => (node.id === 'recommendations')); /*************/ /* user page */ /*************/ } else if (pathname.startsWith('/user/show/')) { add_thumbnail_observer((node) => (node.id === 'recommendations')); } /******************/ /* content-loaded */ /******************/ async function init() { // sitefix for flagged posts not always showing red border // issue: "flagged" style is defined before "has-parent" and "has-children" CSS styles, so these two take priority // fix: just add another copy of the "flagged" style at the end const css = 'img.has-children {padding:0px;border:2px solid #A7DF38;} img.has-parent {padding:0px;border:2px solid #CCCC00;} img.flagged {padding: 0px; border: 2px solid #F00;}' + ' a.tag_nonexistent { color: #E00; }'; // custom style for tag menu add_style(css); add_config_dialog(); if (IS_MONKEY) GM.registerMenuCommand('Open Addon Config', () => show_config_dialog(true), 'C'); add_config_button(); update_config_dialog(); update_headerlogo(); // process what the thumbnail observer may have missed modify_thumbnails(document); /*************************************/ /* main page / visually similar page */ /*************************************/ if (pathname === '/' || pathname === '/post/index' || pathname.startsWith('/post/similar')) { if (config.tag_search_buttons) add_tag_search_buttons(); add_postmode_hotkeys(); if (called_add_mode_options) { // add_mode_options() was called early, as it should PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page } else { // if not, catch up on it later call_postmodemenu_init_workaround = true; } if (!IS_GREASEMONKEY4) { // try to guarantee sitescript has loaded TODO: future note: potential infinite loop while (unsafeWindow.PostModeMenu === undefined) await sleep(100); PostModeMenu_click_original = unsafeWindow.PostModeMenu.click; // TODO will not work on Greasemonkey (need to replace click events just like with the mode change event) unsafeWindow.PostModeMenu.click = PostModeMenu_click_override; } /*************/ /* post page */ /*************/ } else if (pathname.startsWith('/post/show/')) { const hidden_post_id_el = document.getElementById('hidden_post_id'); if (hidden_post_id_el !== null) { post_id = hidden_post_id_el.innerText; } else { const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname); post_id = temp.substring(temp.lastIndexOf('/') + 1); } post_parent_id = document.getElementById('post_parent_id'); if (post_parent_id !== null) parent_id = post_parent_id.value; if (config.view_history_enabled) { config[HISTORY_KEY].add(Number(post_id)); save_setting(HISTORY_KEY, config[HISTORY_KEY]); // save and broadcast view history } if (config.tag_search_buttons) add_tag_search_buttons(); if (config.tag_category_collapser) add_tag_category_collapser(); if (config.add_filetype_stat) add_filetype_stat(); fix_pixiv_source_link(); if (config.move_stats_to_edit_form) move_stats_to_edit_form(); add_tag_buttons(); if (config.tag_menu) add_tag_menu(); update_tag_elements(); // initialize tag menu/buttons add_tags_change_listener(); add_tags_submit_listener(); add_postpage_hotkeys(); if (config.scale_on_resize) add_scale_on_resize_listener(); read_image_data().then(() => { if (config.scale_image) scale_image(config.scale_mode, false); if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center); if (config.sankaku_channel_dark_compatibility) note_fix(); add_resize_notice_listener(); }); find_actions_list().then((actions_ul) => add_addon_actions(actions_ul)); /*************/ /* wiki page */ /*************/ } else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) { add_dtext_style_buttons(); add_wiki_template(); } else if (pathname.startsWith('/wiki/show')) { add_tag_edit_gear(); } } if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') { init().catch((reason) => { show_notice(console.error, 'addon error: init() failed, check console', reason); }); } else { document.addEventListener('DOMContentLoaded', init, false); } })(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);