您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, muting videos, speaker icons on loud videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, duplicate tagging/flagging. Fully configurable using localStorage.
当前为
// ==UserScript== // @name SankakuAddon // @namespace SankakuAddon // @description Adds a few quality of life improvements on Sankaku Channel: Automatic image scaling, muting videos, speaker icons on loud videos, + - tag search buttons, a tag menu which allows for tagging by clicking, 'Choose/Set Parent' modes, duplicate tagging/flagging. Fully configurable using localStorage. // @author sanchan // @include https://chan.sankakucomplex.com/* // @include https://idol.sankakucomplex.com/* // @run-at document-start // @version 0.99.19 // @grant GM.openInTab // @grant unsafeWindow // ==/UserScript== (function(unsafeWindow) { 'use strict'; const VERSION = 'v0.99.19'; // 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 seem to support 'GM.' functions) if (typeof GM !== 'undefined' && typeof GM.info === 'object') { is_monkey = true; is_greasemonkey4 = (GM.info.scriptHandler === 'Greasemonkey'); } function console_log(...args) { if (typeof window.console === 'undefined') return; const msgs = args.map((a) => (typeof a === 'object' ? JSON.parse(JSON.stringify(a)) : a)); window.console.log(...msgs); } function open_in_tab(url) { if (is_monkey) GM.openInTab(url, false); else window.open(url); // requires popup permission } function show_notice(msg) { if (unsafeWindow.notice) unsafeWindow.notice(msg); console_log(msg); } /***************************/ /* configuration functions */ /***************************/ const DEFAULT_CONFIG = { scroll_to_image: true, scale_image: true, // and video scale_only_downscale: false, scale_flash: false, scale_mode: 0, scale_on_resize: false, scroll_to_image_center: true, video_pause: false, video_mute: true, set_video_volume: false, video_volume: 50, video_controls: true, show_speaker_icon: true, show_animated_icon: true, setparent_deletepotentialduplicate: false, editform_deleteuselesstags: false, hide_headerlogo: false, tag_search_buttons: true, tag_menu: true, tag_menu_scale: '30%', tag_menu_layout: 1, common_tags_json: DEFAULT_TAGLIST, sankaku_channel_dark_compatibility: false, }; let USE_LOCAL_STORAGE; try { USE_LOCAL_STORAGE = !!localStorage.getItem; } catch /* DOMException */ { USE_LOCAL_STORAGE = false; } const KEY_PREFIX = 'config.'; // used to avoid conflicts in localStorage and config element ids const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG)); // load default let dom_content_loaded = false; // 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); } function save_config(key, value, warn = true) { value = fix_config_entry(key, value); if (!USE_LOCAL_STORAGE) { if (warn) show_notice(`addon: couldn't save setting "${key} = ${value}" to localStorage. check permissions.`); return; } try { localStorage.setItem(KEY_PREFIX + key, JSON.stringify(value)); } catch (error) { show_notice(`addon: couldn't save setting "${KEY_PREFIX + key} = ${value}" to localStorage, check the console.`); console_log('addon error: ', error); } } function load_config() { for (const key in config) { if (config.hasOwnProperty(key)) { let value = config[key]; // default already loaded if (USE_LOCAL_STORAGE) { const stored_value = localStorage.getItem(KEY_PREFIX + key); if (stored_value !== null) { value = JSON.parse(stored_value); } } config_changed(key, value); // fire regardless } } } function local_storage_changed(e) { if (e.key.startsWith(KEY_PREFIX)) { const key = e.key.substring(KEY_PREFIX.length); const value = JSON.parse(e.newValue); config_changed(key, value); } } // event that is fired whenever a setting changes in the config dialog or the local storage function config_changed(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(); } // UI hasn't loaded yet if (!dom_content_loaded) return; update_config_dialog_by_key(key); if (key === 'hide_headerlogo') update_headerlogo(); } function reset_config() { // clear local storage if (USE_LOCAL_STORAGE) { for (const key in config) { if (config.hasOwnProperty(key)) { localStorage.removeItem(KEY_PREFIX + key); } } } for (const key in config) { if (config.hasOwnProperty(key)) { config_changed(key, DEFAULT_CONFIG[key]); } } } // template for the config dialog const CONFIG_TEMPLATE = { scroll_to_image: {type: 'checkbox', desc: 'Scroll to image/video when opening post'}, scroll_to_image_center: {type: 'checkbox', desc: 'Scroll to center of image/video, else scroll to top'}, scale_image: {type: 'checkbox', desc: 'Scale image/video when opening post'}, scale_only_downscale: {type: 'checkbox', desc: 'Only downscale'}, scale_flash: {type: 'checkbox', desc: 'Also scale flash videos'}, scale_on_resize: {type: 'checkbox', desc: 'Scale image on window resize', title: 'This uses the \'scale image mode\' setting, so it doesn\'t work well when using the manual scaling actions.'}, scale_mode: {type: 'select', desc: 'Scale image/video mode: ', options: {0: 'Fit to window', 1: 'Fit horizontally', 2: 'Fit vertically'}}, video_pause: {type: 'checkbox', desc: 'Pause (non-flash) videos*'}, video_mute: {type: 'checkbox', desc: 'Mute (non-flash) videos*'}, set_video_volume: {type: 'checkbox', desc: 'Set (non-flash) video volume to: '}, video_controls: {type: 'checkbox', desc: 'Show video controls*'}, tag_search_buttons: {type: 'checkbox', desc: 'Show + - tag search buttons*'}, show_speaker_icon: {type: 'checkbox', desc: 'Show 🔊 icon on thumbnail if it has audio*'}, show_animated_icon: {type: 'checkbox', desc: 'Show ⏩ icon on thumbnail if it is animated (🔊 overrides ⏩)*'}, setparent_deletepotentialduplicate: {type: 'checkbox', desc: 'Delete potential_duplicate tag when using "Set Parent"'}, editform_deleteuselesstags: {type: 'checkbox', desc: '"Save changes" button deletes useless_tags tag (if there have been changes)'}, hide_headerlogo: {type: 'checkbox', desc: 'Hide header logo'}, sankaku_channel_dark_compatibility: {type: 'checkbox', desc: 'Galinoa\'s Sankaku Channel Dark compatibilty*'}, tag_menu: {type: 'checkbox', desc: 'Activate tag menu*'}, common_tags_json: {type: 'text', desc: 'Common tags list (JSON format):'}, tag_menu_layout: {type: 'select', desc: 'Tag menu layout: ', options: {0: 'Normal', 1: 'Vertically compact'}}, }; // whether a config element's value are accessed via '.value' (or otherwise '.checked') function is_value_element(key) { if (key === 'video_volume') return true; // "video_volume" is hardcoded in add_config_dialog() if (key === 'tag_menu_scale') return true; // doesn't exist as an element, but it would be '.value' type const type = CONFIG_TEMPLATE[key].type; return (type === 'select' || type === 'text'); } // calls f(cfg_elem, key, get_value) for each existing config element function foreach_config_element(f) { for (const key in config) { if (config.hasOwnProperty(key)) { 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 in config) { if (config.hasOwnProperty(key)) { 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 ? 'table' : '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.toGMTString() + "; path=/"; } function get_cookie(name) { const cookies = document.cookie.split(';'); for (let cookie of cookies) { const kv = cookie.split('='); if (kv.length === 2 && kv[0].trim() === name) { return decodeURIComponent(kv[1]); } } return ""; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // helper function to modify nodes on creation function register_observer(node_predicate, node_modifier) { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node_predicate(node)) { if (node_modifier(node, observer)) { // are we done? observer.disconnect(); return; } } } } }); observer.observe(document, {childList: true, subtree: true}); return observer; } function get_scrollbar_width() { // from Stack Overflow const outer = document.createElement('DIV'); outer.style.visibility = 'hidden'; outer.style.width = '100px'; document.body.appendChild(outer); const width_no_scroll = outer.offsetWidth; outer.style.overflow = 'scroll'; // force scrollbars const inner = document.createElement('DIV'); inner.style.width = '100%' + width_no_scroll; outer.appendChild(inner); const width_with_scroll = inner.offsetWidth; outer.parentNode.removeChild(outer); return width_no_scroll - width_with_scroll; } 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]); 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 functions */ /*********************/ let header_offset_height = null; // distance needed to scroll if headerlogo is hidden/shown function hide_headerlogo(hide) { const headerlogo = document.getElementById('headerlogo'); if (headerlogo === null) return; const visible = (headerlogo.style.display !== 'none'); headerlogo.style.display = (hide ? 'none' : ''); // scroll if needed if (visible === !!hide) window.scrollBy(0, (visible && hide ? -1 : 1) * header_offset_height); } function add_config_dialog() { const cfg_dialog = document.createElement('DIV'); cfg_dialog.id = 'cfg_dialog'; cfg_dialog.style.display = 'none'; // show_config_dialog() switches this with "table" so that centeringDiv works cfg_dialog.style.border = '1px solid ' + shifted_backgroundColor(32); cfg_dialog.style.top = '50%'; cfg_dialog.style.transform = 'translateY(-50%)'; cfg_dialog.style.position = 'fixed'; cfg_dialog.style.left = '0'; cfg_dialog.style.right = '0'; cfg_dialog.style.margin = '0 auto'; cfg_dialog.style.overflow = 'auto'; cfg_dialog.style.backgroundColor = get_original_background_color(); cfg_dialog.style.zIndex = '10002'; const centeringDiv = document.createElement('DIV'); centeringDiv.style.display = 'table-cell'; centeringDiv.style.verticalAlign = 'middle'; cfg_dialog.appendChild(centeringDiv); const innerDiv = document.createElement('DIV'); innerDiv.style.margin = '12px'; innerDiv.id = 'cfg_dialog_inner'; centeringDiv.appendChild(innerDiv); // generate the content of the config menu let innerDivHTML = `<div style='font-weight: bold;'>Sankaku Addon ${VERSION}</div>` + `<hr style='margin-top: 0; margin-bottom: 2px; border:1px solid ${shifted_backgroundColor(32)};'>`; // parse the config_template for (const [key, value] of Object.entries(CONFIG_TEMPLATE)) { const type = value.type; const generate_span = function(value) { return `<span style="vertical-align: middle; ${value.title ? 'cursor:help; text-decoration: underline dashed; ' : ''}" ` + `${value.title ? `title="${value.title}"` : ''} >${value.desc}</span>`; }; innerDivHTML += '<div>'; switch (type) { case 'checkbox': innerDivHTML += `<input id='${KEY_PREFIX}${key}' type='checkbox' style='vertical-align: middle;'>`; innerDivHTML += generate_span(value); // 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(value); 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(value); innerDivHTML += `<textarea id="${KEY_PREFIX}${key}" style='width: 100%;'></textarea>`; break; } innerDivHTML += '</div>'; } innerDivHTML += '<div>'; innerDivHTML += '<button id="config_close" style="cursor: pointer;">Close</button>'; innerDivHTML += '<button id="config_reset" style="cursor: pointer;">Reset settings</button>'; innerDivHTML += '</div>'; innerDivHTML += '<div> *requires a page reload.</div>'; innerDiv.innerHTML = innerDivHTML; document.body.appendChild(cfg_dialog); // add events document.getElementById('config_close').onclick = function() { show_config_dialog(false); return false; }; document.getElementById('config_reset').onclick = function() { reset_config(); return false; }; foreach_config_element((cfg_elem, key, get_value) => { cfg_elem.addEventListener('change', () => { config_changed(key, get_value()); save_config(key, get_value()); }); }); } function add_config_button() { const navbar = document.getElementById('navbar'); if (navbar === null) { show_notice('addon error: couldn\'t find "navbar" element! Config dialog disabled.'); 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 = function() { show_config_dialog(true); return false; }; a.innerHTML = '<span style="font-size: 110%;">⚙</span> Addon config'; a.style.fontSize = '120%'; const li = document.createElement('LI'); li.className = 'lang-select'; // match style of top bar li.appendChild(a); navbar.appendChild(li); } function add_tag_search_buttons() { const tagsidebar = document.getElementById('tag-sidebar'); if (tagsidebar === null) return; const items = tagsidebar.getElementsByTagName('LI'); for (const item of items) { const taglink = item.getElementsByTagName('A')[0]; const tagname = taglink.innerText.replace(/ /g, '_'); // hopefully this is the only edgecase // generates click listeners const tag_search_button_func = function(tagname) { return function() { const search_field = document.getElementById('tags'); const search_tags = search_field.value.trim().split(/\s+/); const tag_index = search_tags.indexOf(tagname); // add tag if missing, remove if existing if (tag_index === -1) { search_field.value = (search_field.value + ' ' + tagname).trim(); } else { search_tags.splice(tag_index, 1); search_field.value = search_tags.join(' '); } search_field.focus({preventScroll: true}); return false; }; }; { const a = document.createElement('A'); a.href = '#'; a.innerText = '+'; a.onclick = tag_search_button_func(tagname); item.insertBefore(a, taglink); item.insertBefore(document.createTextNode(' '), taglink); } { const a = document.createElement('A'); a.href = '#'; a.innerText = '-'; a.onclick = tag_search_button_func('-' + tagname); item.insertBefore(a, taglink); item.insertBefore(document.createTextNode(' '), taglink); } } } function add_speaker_icons(root) { if (!(config.show_speaker_icon || config.show_animated_icon)) return; if (root === null) return; const elems = root.getElementsByTagName('SPAN'); for (const elem of elems) { if (elem.classList.contains('thumb')) { add_speaker_icon(elem); } } } 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; if (thumb_span.getElementsByClassName('speaker_icon').length > 0) return; // already has speaker icon const icon = document.createElement('SPAN'); const tags = img.title.trim().split(/\s+/); if (config.show_speaker_icon && (tags.indexOf('has_audio') !== -1)) { icon.innerText = '🔊'; } else if (config.show_animated_icon && (tags.indexOf('animated') !== -1 || tags.indexOf('video') !== -1)) { icon.innerText = '⏩'; } else { return; } icon.className = 'speaker_icon'; icon.style.color = '#666'; icon.style.position = 'absolute'; icon.style.top = '2px'; // account for border icon.style.right = '2px'; icon.style.fontSize = '200%'; icon.style.textShadow = '-1px 0 white, 0 1px white, 1px 0 white, 0 -1px white'; icon.style.transform = 'translateX(50%) translateY(-50%)'; a[0].style.display = 'inline-block'; // makes the element fit its content a[0].style.position = 'relative'; a[0].appendChild(icon); } function add_speaker_icons_observer(predicate) { // don't hog CPU when disabled, but requires page reload to activate if (!(config.show_speaker_icon || config.show_animated_icon)) return; // this might observe recommendations too early, so add missing thumbnail icons in DOMContentLoaded register_observer(predicate, (node, observer) => { add_speaker_icons(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 mode_dropdown = null; let added_mode_options = false; let call_postmodemenu_change = false; function add_mode_options(dropdown) { if (added_mode_options) return; added_mode_options = true; mode_dropdown = dropdown ? dropdown : document.getElementById('mode'); // 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_change) { PostModeMenu_init_workaround(); // guarantee that 'mode' variable correctly changes to new modes when loading page } } function add_postmode_hotkeys() { document.addEventListener('keydown', (e) => { 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; } } switch (e.key) { case 'v': if (is_greasemonkey4) { show_notice('addon error: \'Set Parent\' not yet supported in Greasemonkey'); return; } mode_dropdown.value = 'set-parent'; break; case 'c': if (is_greasemonkey4) { show_notice('addon error: \'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; } PostModeMenu_change_override(); }, true); } function PostModeMenu_init_workaround() { // issue: new post modes can reset on page load if they were added too late // reason: on page load, PostModeMenu.init reads the "mode" cookie, tries to set mode_dropdown.value, then // calls PostModeMenu.change, which sets the cookie to mode_dropdown.value. // so if the new modes aren't added yet, mode_dropdown.value and the "mode" cookie will both reset // solution: safe mode in a separate 'backup' cookie and set the "mode" cookie and mode_dropdown after new modes were added const mode = get_cookie('addon_mode'); if (mode !== '') { set_cookie('mode', mode, 7); mode_dropdown.value = mode; } PostModeMenu_change_override(); } async function PostModeMenu_change_override() { if (!added_mode_options) return; const s = mode_dropdown.value; // try to guarantee sitescript has loaded while (unsafeWindow.PostModeMenu === undefined || unsafeWindow.Cookie === undefined || unsafeWindow.$ === undefined) await sleep(100); unsafeWindow.PostModeMenu.change(); set_cookie('addon_mode', s, 7); // set 'backup' cookie const darkmode = is_darkmode(); if (s === 'add-fav') { // FFFFAA, original. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#505000' : '#FFA'); } else if (s === 'remove-fav') { // FFFFAA -> FFEEAA, slightly more orange. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#504000' : '#FEA'); } else if (s === 'apply-tag-script') { // AA33AA -> FFDDFF, weaken color intensity. darkmode: luminance 40 document.body.style.backgroundColor = (darkmode ? '#500050' : '#FDF'); } else if (s === 'approve') { // 2266AA -> FFDDFF, increase contrast to unapproved posts. darkmode: TODO document.body.style.backgroundColor = '#FDF'; } else if (s === 'choose-parent') { document.body.style.backgroundColor = (darkmode ? '#464600' : '#FFD'); } else if (s === 'set-parent') { if (get_cookie('chosen-parent') === '') { show_notice('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 if (!added_mode_options) return false; // not logged in const s = mode_dropdown.value; if (s === 'choose-parent') { set_cookie('chosen-parent', post_id); mode_dropdown.value = 'set-parent'; PostModeMenu_change_override(); } else if (s === 'set-parent') { const parent_id = get_cookie('chosen-parent'); unsafeWindow.TagScript.run(post_id, 'parent:' + parent_id + (config.setparent_deletepotentialduplicate ? ' -potential_duplicate' : '')); } return false; } /***********************/ /* post page functions */ /***********************/ let post_parent_id = null; // input elem // original post/parent ids let post_id = null; let parent_id = null; let image_data = null; let resize_timer; let tag_update_timer; let tags_changed = false; // set by find_actions_list(): let actions_ul = null; let found_delete_action = false; let mouse_moved = false; // for tag_menu_scaler function tag_menu_scaler_mousedown(e) { e.preventDefault(); mouse_moved = false; window.addEventListener('mousemove', tag_menu_scaler_mousemove); window.addEventListener('mouseup', tag_menu_scaler_mouseup); } function tag_menu_scaler_mousemove(e) { e.preventDefault(); mouse_moved = true; set_tag_menu_scale(e, false); } function tag_menu_scaler_mouseup(e) { e.preventDefault(); if (mouse_moved) set_tag_menu_scale(e, true); window.removeEventListener('mousemove', tag_menu_scaler_mousemove); window.removeEventListener('mouseup', tag_menu_scaler_mouseup); } function set_tag_menu_scale(e, save) { const tag_menu = document.getElementById('tag_menu'); if (tag_menu === null) return; const yFromBottom = window.innerHeight - e.clientY; let yPercentfromBottom = (100.0 * (yFromBottom / window.innerHeight)); yPercentfromBottom = Math.min(Math.max(yPercentfromBottom, 5), 95) + '%'; tag_menu.style.height = yPercentfromBottom; if (save) save_config('tag_menu_scale', yPercentfromBottom, false); } 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 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_json); } catch (error) { show_notice('addon error: "common tags" JSON syntax error'); return; } if (!Array.isArray(tag_data)) { show_notice('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('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('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('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('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('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(updated_tags, 500); }); } // also used for 'tag_menu_save' button function tags_submit_listener() { delete_useless_tags_tag(); return true; // actually submit? } function add_tags_submit_listener() { const edit_form = document.getElementById('edit-form'); if (edit_form === null) return; // not logged in edit_form.addEventListener('submit', (e) => { if (!tags_submit_listener()) e.preventDefault(); }); } function find_actions_list() { const li = document.getElementById('add-to-pool'); if (li === null) return; actions_ul = li.parentElement; const action_links = actions_ul.getElementsByTagName('A'); for (const action_link of action_links) { if (action_link.innerText === 'Delete') { found_delete_action = true; break; } } } function add_addon_actions() { if (actions_ul === null) 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( 0, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image', 'scale-image-fit'); add_action(() => { scale_image( 1, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Horizontal)', 'scale-image-hor'); add_action(() => { scale_image( 2, true); scroll_to_image(config.scroll_to_image_center); }, 'Fit image (Vertical)', 'scale-image-ver'); add_action(() => { scale_image(-1, 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('addon error: couldn\'t find post id?! Flag duplicate feature disabled.'); return; } add_action(() => { flag_duplicate(post_id, ''); }, 'Flag duplicate', 'flag-duplicate'); add_action(() => { flag_duplicate(post_id, ', visually identical'); }, 'Flag duplicate (identical)', 'flag-duplicate-identical'); add_action(() => { flag_duplicate(post_id, ' with worse quality'); }, 'Flag duplicate (quality)', 'flag-duplicate-quality'); add_action(() => { flag_duplicate(post_id, ' with worse resolution'); }, 'Flag duplicate (resolution)', 'flag-duplicate-resolution'); } function add_tag_buttons() { const edit_form = document.getElementById('edit-form'); if (edit_form === null) return; // not logged in const button_place = edit_form.children[1].children[0].children[0].children[0]; button_place.style.whiteSpace = 'nowrap'; { const el = document.createElement('BUTTON'); el.id = 'clear_parent_id_button'; el.style.margin = '0 3px 0 6px'; el.innerText = 'Clear'; el.onclick = () => { post_parent_id.clear(); return false; }; post_parent_id.parentNode.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'reset_parent_id_button'; el.style.margin = '0 3px'; el.innerText = 'Reset'; el.onclick = () => { reset_parent_id(); return false;}; post_parent_id.parentNode.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_reset_button'; el.style.margin = '0 3px 0 6px'; el.innerText = 'Reset'; el.onclick = () => { reset_tags(); return false; }; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_dup_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_var_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } { const el = document.createElement('BUTTON'); el.id = 'tag_pot_button'; el.style.margin = '0 3px'; button_place.appendChild(el); } } function update_tag_buttons() { const taglist = document.getElementById('post_tags'); const dup_button = document.getElementById('tag_dup_button'); const var_button = document.getElementById('tag_var_button'); const pot_button = document.getElementById('tag_pot_button'); if (taglist === null || dup_button === null || var_button === null || pot_button === null) return; const tags = get_tags_array(); if (tags.indexOf('duplicate') === -1) { 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.indexOf('legitimate_variation') === -1) { 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.indexOf('potential_duplicate') === -1) { 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.indexOf('legitimate_variation') !== -1) || (tag === 'legitimate_variation' && tags.indexOf('duplicate') !== -1)) { show_notice('addon: cannot tag as duplicate and legitimate_variation at the same time.'); return; } if (tags.indexOf(tag) !== -1) { show_notice('addon: tag already present.'); return; } document.getElementById('post_tags').value += ' ' + tag; tags_changed = true; updated_tags(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; updated_tags(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().indexOf(tag) !== -1); } function reset_tags() { document.getElementById('post_tags').value = document.getElementById('post_old_tags').value; tags_changed = false; updated_tags(); } // event that gets called whenever post_tags changes function updated_tags(skip_common_tags_update = 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_update); } } // flag option with default text function flag_duplicate(id, reason_suffix) { if (is_greasemonkey4) { show_notice('addon error: \'Flag duplicate\' not yet supported in Greasemonkey'); return; } if (parent_id === null) { show_notice('addon: user not logged in'); return; } const current_parent_id = post_parent_id.value; if (current_parent_id !== parent_id) { show_notice('addon: parent id was changed but not saved!'); return; } if (!current_parent_id || current_parent_id.length === 0) { show_notice('addon: no parent id set!'); return; } const tags = get_tags_array(); const old_tags = get_old_tags_array(); if (tags.indexOf('duplicate') !== -1 && old_tags.indexOf('duplicate') === -1) { show_notice('addon: duplicate tag set but not saved!'); return; } if (old_tags.indexOf('duplicate') === -1) { show_notice('addon: not tagged as duplicate!'); return; } if (old_tags.indexOf('legitimate_variation') !== -1) { show_notice('addon: tagged as legitimate_variation, are you sure it is a duplicate?'); 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('Post was resent to moderation queue'); else show_notice(`Error: ${resp.reason}`); } }); } function read_image_data() { const data = { img_elem: null, // image or video non_img_elem: null, emb_elem: null, // flash or unknown is_flash: null, width: null, height: null, aspect_ratio: null, current_height: null // store current height separately, because flash is a bitch }; let img = document.getElementById('image'); if (img !== null) { // image or video data.img_elem = img; data.is_flash = false; // workaround for Galinoa's Sankaku Channel Dark: don't read .width/.height attributes but read "Details" let res = null; const lowres = document.getElementById('lowres'); if (lowres !== null) { res = lowres.innerText.split('x'); // parse "<width>x<height>" } else { const highres = document.getElementById('highres'); if (highres !== null) { res = highres.innerText.split(' ')[0].split('x'); // parse "<width>x<height> (<file size>)" } } if (res === null) { show_notice('addon error: image/video resolution not in "Details"?! Disabled scaling.'); return null; } data.width = res[0]; data.height = res[1]; } else { // flash or unknown data.non_img_elem = document.getElementById('non-image-content'); if (!data.non_img_elem) return null; data.img_elem = data.non_img_elem.getElementsByTagName('OBJECT')[0]; data.emb_elem = data.non_img_elem.getElementsByTagName('EMBED')[0]; data.is_flash = (data.img_elem !== null && data.emb_elem !== null); img = data.img_elem; // object contains width/height data.width = img.width; data.height = img.height; } data.aspect_ratio = data.width / data.height; data.current_height = data.height; return data; } // stretch image/video/flash, requires data from read_image_data() function scale_image(mode, always_scale) { let img_rect_w, img_rect_h; let new_width, new_height; // read_image_data() failed if (image_data === null) return; if (!always_scale && (!config.scale_flash && image_data.is_flash)) return; // reset image size if (mode === -1) { if (!image_data.is_flash) { image_data.img_elem.style.width = null; image_data.img_elem.style.height = null; } 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; } let left_side; // workaround for Galinoa's Sankaku Channel Dark // problem: seems to only work for bigger windows if (config.sankaku_channel_dark_compatibility) { const sidebar = document.getElementsByClassName('sidebar')[0]; left_side = (sidebar.getBoundingClientRect().right + 12); image_data.img_elem.style.paddingLeft = left_side + 'px'; // don't hide behind sidebar } else { left_side = image_data.img_elem.getBoundingClientRect().left; } // target rect img_rect_w = Math.max(window.innerWidth - left_side - get_scrollbar_width() - 1, 1); img_rect_h = Math.max(window.innerHeight - 1, 1); const img_rect_aspect_ratio = img_rect_w / img_rect_h; // fit into window if (mode === 0) mode = (image_data.aspect_ratio > img_rect_aspect_ratio ? 1 : 2); // horizontal if (mode === 1) { new_width = Math.floor(img_rect_w); new_height = Math.floor(img_rect_w / image_data.aspect_ratio); // vertical } else if (mode === 2) { new_width = Math.floor(img_rect_h * image_data.aspect_ratio); new_height = Math.floor(img_rect_h); } if (!always_scale && (config.scale_only_downscale && (new_width > image_data.width || new_height > image_data.height))) return; const set_dimensions = function(obj, new_width, new_height) { obj.width = new_width + 'px'; obj.height = new_height + 'px'; }; if (image_data.is_flash) { set_dimensions(image_data.img_elem, new_width, new_height); set_dimensions(image_data.emb_elem, new_width, new_height); } else { set_dimensions(image_data.img_elem.style, new_width, new_height); } image_data.current_height = new_height; } function scale_on_resize_helper() { clearTimeout(resize_timer); resize_timer = setTimeout(() => { if (config.scale_on_resize) scale_image(config.scale_mode, false); }, 100); } function add_scale_on_resize_listener() { window.addEventListener('resize', scale_on_resize_helper); } function remove_scale_on_resize_listener() { window.removeEventListener('resize', scale_on_resize_helper); } function scroll_to_image(to_center) { if (image_data === null) return; const absolute_img_top = (image_data.is_flash ? image_data.non_img_elem : image_data.img_elem).getBoundingClientRect().top + window.pageYOffset; if (to_center) { const top_of_centered_rect = absolute_img_top - (window.innerHeight - image_data.current_height) / 2; window.scrollTo(0, top_of_centered_rect); } else { window.scrollTo(0, absolute_img_top); } } // when resize notice is hidden (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(-1, true); scroll_to_image(config.scroll_to_image_center); break; case 'f': // f(it) scale_image(0, true); scroll_to_image(config.scroll_to_image_center); break; case 'g': // g scale_image(1, true); scroll_to_image(config.scroll_to_image_center); break; case 'h': // h scale_image(2, true); scroll_to_image(config.scroll_to_image_center); break; case 's': // s(imilar) if (post_id === null) { show_notice('addon error: couldn\'t find post id?!'); } else { open_in_tab(location.origin + '/post/similar?id=' + post_id); } break; case 'd': // d(elete) if (post_id === null) { show_notice('addon error: couldn\'t find post id?!'); } else if (!found_delete_action) { show_notice('addon error: Delete action not found, no permission?'); } else { open_in_tab(location.origin + '/post/delete/' + post_id); } break; } }, true); } /***********************/ /* wiki page functions */ /***********************/ function add_dtext_style_buttons() { const wiki_form = document.getElementById('wiki-form'); const wiki_body = document.getElementById('wiki_page_body'); if (wiki_form === null || wiki_body === null) { show_notice('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_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('addon error: couldn\'t add "⚙" tag page link, check console'); console_log(error); } } /******************/ /* document-start */ /******************/ load_config(); /*************************************/ /* main page / visually similar page */ /*************************************/ // skip language codes in pathnames like "/jp/post/show" let pathname = 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 for dynamically loaded posts (from auto paging) add_speaker_icons_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_speaker_icons_observer((node) => (node.id === 'recommendations')); /*************/ /* user page */ /*************/ } else if (pathname.startsWith('/user/show/')) { add_speaker_icons_observer((node) => (node.id === 'recommendations')); } /******************/ /* content-loaded */ /******************/ async function init() { dom_content_loaded = true; // sitefix for flagged posts not always showing red border // problem: "flagged" style is defined before "has-parent" and "has-children" CSS styles, so these two take priority // fix: just add another copy of the "flagged" style at the end const sheet = document.createElement('STYLE'); sheet.innerHTML = 'img.has-children {padding:0px;border:2px solid #A7DF38;} img.has-parent {padding:0px;border:2px solid #CCCC00;} img.flagged {padding: 0px; border: 2px solid #F00;}' + ' a.tag_nonexistent { color: #E00; }'; // custom style for tag menu document.body.appendChild(sheet); const headerlogo = document.getElementById('headerlogo'); if (headerlogo !== null) { header_offset_height = headerlogo.offsetHeight; update_headerlogo(); } add_config_dialog(); add_config_button(); update_config_dialog(); // listen for config changes in other windows window.addEventListener('storage', local_storage_changed); //add speaker icons that the observer may have missed add_speaker_icons(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 (added_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_change = true; } if (!is_greasemonkey4) { // try to guarantee sitescript has loaded while (unsafeWindow.PostModeMenu === undefined) await sleep(100); PostModeMenu_click_original = unsafeWindow.PostModeMenu.click; // TODO will not work on Greasemonkey (need to replace click events just like with the mode change event) unsafeWindow.PostModeMenu.click = PostModeMenu_click_override; } /*************/ /* post page */ /*************/ } else if (pathname.startsWith('/post/show/')) { const hidden_post_id_el = document.getElementById('hidden_post_id'); if (hidden_post_id_el !== null) { post_id = hidden_post_id_el.innerText; } else { post_id = pathname.substring(pathname.lastIndexOf('/') + 1); } post_parent_id = document.getElementById('post_parent_id'); if (post_parent_id !== null) parent_id = post_parent_id.value; find_actions_list(); add_addon_actions(); add_tag_buttons(); if (config.tag_menu) add_tag_menu(); updated_tags(); // initialize tag menu/buttons if (config.tag_search_buttons) add_tag_search_buttons(); image_data = read_image_data(); if (config.scale_image) scale_image(config.scale_mode, false); if (config.scroll_to_image) scroll_to_image(config.scroll_to_image_center); add_resize_notice_listener(); if (config.sankaku_channel_dark_compatibility) note_fix(); add_postpage_hotkeys(); if (config.scale_on_resize) add_scale_on_resize_listener(); add_tags_change_listener(); add_tags_submit_listener(); /*************/ /* wiki page */ /*************/ } else if (pathname.startsWith('/wiki/add') || pathname.startsWith('/wiki/edit')) { add_dtext_style_buttons(); } else if (pathname.startsWith('/wiki/show')) { add_tag_edit_gear(); } } if (document.readyState === 'complete' || document.readyState === 'loaded' || document.readyState === 'interactive') { init(); } else { document.addEventListener('DOMContentLoaded', init, false); } })(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);