SankakuAddon

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.

Gammal: v0.99.61 - 2022-03-05 - readd missing thumbnail padding (so thumbnail icons don't jump around) change the modified border color priorities to what it was before
Ny: v0.99.66 - 2022-03-06 - the cursed update: add cool new SVG thumbnail icons! allow editing posts on the deletion page deletion page: add critial tags below their thumbnail (and also check for potential_upscale tag) deletion page: check resolutions for exact integer multiples (potential upscale) make beta link more useful by direct linking to appropriate pages (thanks to Octopus Hugger!) add option for post border style: prioritizing blue "unapproved" over yellow/green "has parent/children" or vice versa select tag script templates using 1-0 hotkeys fix bug where "Edit Tags" mode would resubmit previously submitted data

  • --- /tmp/diffy20251003-3024302-ggqf5z 2025-10-03 07:07:00.586214033 +0000
  • +++ /tmp/diffy20251003-3024302-weuebp 2025-10-03 07:07:00.587214048 +0000
  • @@ -3,7 +3,7 @@
  • // @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.61
  • +// @version 0.99.66
  • // @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/*
  • @@ -25,7 +25,89 @@
  • (async function(unsafeWindow) {
  • 'use strict';
  • - const VERSION = 'v0.99.61';
  • + const VERSION = 'v0.99.66';
  • +
  • + const SVG_SIZE = 20;
  • + const SPEAKER_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  • + <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  • + <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  • + <rect width="32" height="2" stroke-width=".074927"/>
  • + <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect y="30" width="32" height="2" stroke-width=".07698"/>
  • + </g>
  • + <path d="m19 11c6 5 0 10 0 10" fill="none" stroke="#ff761c" stroke-width="2"/>
  • + <path d="m23 9c8 7 0 14 0 14" fill="none" stroke="#ff761c" stroke-width="2"/>
  • + <path d="m16 23h-3l-3-3h-5v-8h5l3-3h3" fill="#ff761c"/>
  • +</svg>`;
  • +
  • + const ANIMATED_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  • + <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  • + <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  • + <rect width="32" height="2" stroke-width=".074927"/>
  • + <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect y="30" width="32" height="2" stroke-width=".07698"/>
  • + </g>
  • + <path d="m18 16-10 6v-12" fill="#ff761c"/>
  • + <path d="m26 16-9 6v-12" fill="#ff761c"/>
  • +</svg>`;
  • +
  • + const EXPLICIT_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  • + <g>
  • + <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  • + <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  • + <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  • + <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  • + </g>
  • + <g transform="scale(.9953 1.0047)" fill="#f99" aria-label="S">
  • + <rect x="12.057" y="5.9718" width="11.052" height="2.9859" stroke-width="1.0856"/>
  • + <rect x="12.057" y="22.892" width="11.052" height="2.9859" stroke-width="1.0856"/>
  • + <rect x="12.057" y="14.432" width="9.0425" height="2.9859" stroke-width=".98198"/>
  • + <rect x="9.0425" y="5.9718" width="3.0142" height="19.906" stroke-width="1.0541"/>
  • + </g>
  • + </g>
  • +</svg>`;
  • +
  • + const QUESTIONABLE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  • + <g>
  • + <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  • + <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  • + <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  • + <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  • + </g>
  • + <g transform="translate(6.9551 5.0704)" fill="#999" aria-label="S">
  • + <path d="m11.545 14.93 5 4-2 2-5-4" fill="#999"/>
  • + </g>
  • + <text x="-13" y="4" font-family="'Andika New Basic'" font-size="40px" style="line-height:1.25" xml:space="preserve"><tspan x="-13" y="4"/></text>
  • + <path d="m16 6.5a7 9.5 0 0 0-7 9.5 7 9.5 0 0 0 7 9.5 7 9.5 0 0 0 7-9.5 7 9.5 0 0 0-7-9.5zm0 1.5a5 8 0 0 1 5 8 5 8 0 0 1-5 8 5 8 0 0 1-5-8 5 8 0 0 1 5-8z" fill="#999"/>
  • + </g>
  • +</svg>`;
  • +
  • + const SAFE_SVG = `<svg width="${SVG_SIZE}" height="${SVG_SIZE}" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
  • + <g>
  • + <rect x="2" y="2" width="28" height="28" fill-opacity=".67" fill-rule="evenodd" stroke-width=".22631"/>
  • + <g fill="#fff" fill-opacity=".33" fill-rule="evenodd">
  • + <rect x="-6.0956e-8" y="-1.3512e-9" width="32" height="2" stroke-width=".074927"/>
  • + <rect x="30" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="2" width="2" height="28" stroke-width=".075839"/>
  • + <rect x="-6.0956e-8" y="30" width="32" height="2" stroke-width=".07698"/>
  • + </g>
  • + <g transform="scale(.9953 1.0047)" fill="#9f9" aria-label="S">
  • + <path d="m21.586 9.7289q-0.78306-0.39548-1.4501-0.65914-0.65255-0.27684-1.2761-0.44822-0.62355-0.18456-1.2471-0.26366-0.62355-0.079096-1.3196-0.079096-0.87006 0-1.5951 0.23729-0.71055 0.23729-1.2326 0.63277-0.52204 0.39548-0.81206 0.90961-0.27552 0.51413-0.27552 1.0546 0 0.5405 0.15951 0.98871 0.17401 0.43503 0.68155 0.83052 0.50754 0.3823 1.4501 0.7646 0.94257 0.36912 2.4942 0.77779 1.4356 0.3823 2.5377 0.89643 1.1166 0.50095 1.8706 1.1601t1.1456 1.5028q0.39153 0.8437 0.39153 1.9115 0 1.3315-0.58004 2.4256-0.56554 1.081-1.5661 1.872-0.98607 0.77779-2.3057 1.2128-1.3196 0.42185-2.7987 0.42185-1.0441 0-2.0447-0.13183-0.98607-0.13183-1.8706-0.36912-0.87006-0.23729-1.6096-0.55368-0.73955-0.32957-1.2906-0.72506l0.65255-2.7025q1.4501 1.1469 2.9872 1.661 1.5516 0.51413 3.1757 0.51413 0.87006 0 1.6821-0.22411t1.4356-0.64596q0.62355-0.43503 1.0006-1.0546 0.37703-0.63278 0.37703-1.4369 0-0.51413-0.18851-0.97553-0.17401-0.47458-0.72505-0.9228-0.55104-0.44822-1.5806-0.88325-1.0296-0.44822-2.7262-0.90961-1.6966-0.4614-2.7697-1.0151-1.0731-0.55368-1.6821-1.1996-0.60904-0.65914-0.84106-1.3974-0.21752-0.75142-0.21752-1.5951 0-0.8437 0.37703-1.7797 0.39153-0.93598 1.2181-1.7269 0.82656-0.79097 2.1172-1.3051 1.2906-0.52731 3.1032-0.52731 0.84106 0 1.5661 0.065914 0.73956 0.065914 1.4211 0.21092 0.69605 0.14501 1.3631 0.36912 0.66705 0.22411 1.3776 0.5405z" fill="#9f9"/>
  • + </g>
  • + </g>
  • +</svg>`;
  • +
  • + const RATING_SVG = {
  • + e: EXPLICIT_SVG,
  • + q: QUESTIONABLE_SVG,
  • + s: SAFE_SVG,
  • + };
  • // based on the Tag Checklist in the wiki
  • const DEFAULT_TAGLIST =
  • @@ -474,13 +556,17 @@
  • applied_css = true;
  • // change priority of post borders (by redefining their colors after their original definition)
  • - // before: flagged < has-children < has-parent < pending < deleted
  • - // after: pending < deleted < has-children < has-parent < flagged
  • - // note: deleted posts only show through explicit search so they don't need a border
  • - // TODO maybe pending should be above has-children and has-parent?
  • + // original: flagged < has-children < has-parent < pending < deleted
  • + // default: deleted < has-children < has-parent < pending < flagged
  • + // variants: pending < deleted < has-children < has-parent < flagged
  • + // note: pending, flagged and deleted are mutually exclusive, so this is only about their relation to has-children/parent
  • + // also note: deleted posts only show through explicit search so they don't really need a border
  • add_style(`
  • img.has-children { border-color: #A7DF38; }
  • img.has-parent { border-color: #CCCC00; }
  • + ${config.post_border_style === 0 ? `
  • + img.pending { border-color: #4B4BA3; }
  • + ` : ''}
  • img.flagged { border-color: #F00; }
  • `);
  • @@ -501,19 +587,28 @@
  • overflow: auto; /* fit its content */
  • }
  • - /* balance the vertical margins */
  • + /* balance margins */
  • + #content > .deleting-post {
  • + padding-top: 1em;
  • + padding-bottom: 1em;
  • + }
  • #content > .deleting-post > div {
  • margin-top: unset !important;
  • margin-bottom: unset !important;
  • }
  • #content > .deleting-post > ul {
  • - margin-top: 1em;
  • + margin-bottom: unset;
  • }
  • /* align first thumbnail with comparison box */
  • #content > .thumb {
  • margin-left: calc(4em + 4px);
  • }
  • +
  • + /* adjustment to center tags below thumbnails */
  • + #content .deleting-post .thumb > * {
  • + margin: auto;
  • + }
  • `);
  • /* replace thumbnail centering logic (more robust, needed for thumbnail icons) */
  • @@ -522,7 +617,7 @@
  • display: grid !important;
  • place-content: center;
  • }
  • -
  • .thumb .preview {
  • position: static !important;
  • }
  • @@ -618,6 +713,7 @@
  • show_speaker_icon: true,
  • show_animated_icon: true,
  • show_ratings_icon: false,
  • + post_border_style: 0,
  • setparent_deletepotentialduplicate: false,
  • editform_deleteuselesstags: false,
  • hide_headerlogo: false,
  • @@ -686,6 +782,7 @@
  • tag_menu_layout: Number,
  • tag_category_collapser_style: Number,
  • highres_limit: Number,
  • + post_border_style: Number,
  • };
  • function fix_config_entry(key, value) {
  • @@ -887,6 +984,7 @@
  • 'show_animated_icon',
  • 'show_ratings_icon',
  • 'view_history_enabled',
  • + 'post_border_style',
  • 'hide_headerlogo',
  • 'sankaku_channel_dark_compatibility',
  • ],
  • @@ -932,10 +1030,11 @@
  • 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 ⏩)*'},
  • - show_ratings_icon: {type: 'checkbox', desc: 'Show ratings icon (S, Q, E) on post thumbnails*'},
  • + show_speaker_icon: {type: 'checkbox', desc: `Show ${SPEAKER_SVG} icon on thumbnail if it has audio*`},
  • + show_animated_icon: {type: 'checkbox', desc: `Show ${ANIMATED_SVG} icon on thumbnail if it is animated (${SPEAKER_SVG} overrides ${ANIMATED_SVG} )*`},
  • + show_ratings_icon: {type: 'checkbox', desc: `Show ratings icon (${SAFE_SVG}, ${QUESTIONABLE_SVG}, ${EXPLICIT_SVG}) on post thumbnails*`},
  • view_history_enabled: {type: 'checkbox', desc: 'Fade out thumbnails of viewed posts (enables post view history)*'},
  • + post_border_style: {type: 'select', desc: 'Post border style: ', options: {0: 'Prioritize blue \'unapproved\' border', 1: 'Priorizite yellow/green \'has parent / children\' borders'}},
  • 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*'},
  • @@ -1298,7 +1397,7 @@
  • }
  • innerDivHTML += '<div style="padding: 2px">';
  • - innerDivHTML += '<button id="config_close" style="cursor: pointer;">Close</button>';
  • + innerDivHTML += '<button id="config_close" style="cursor: pointer; margin-right: 6px">Close</button>';
  • innerDivHTML += '<button id="config_reset" style="cursor: pointer;" title="Resets all settings to default (but doesn\'t clear post history)">Reset settings</button>';
  • innerDivHTML += '<button id="history_clear" style="cursor: pointer;" title="Clears the post view history for the current site (chan or idol)">Clear post view history</button>';
  • innerDivHTML += '</div>';
  • @@ -1306,6 +1405,12 @@
  • cfg_dialog.innerHTML = innerDivHTML;
  • + // adjust inline SVG icons
  • + for (const svg of cfg_dialog.querySelectorAll('svg')) {
  • + svg.style.width = '1rem';
  • + svg.style.verticalAlign = 'middle';
  • + }
  • +
  • document.body.appendChild(cfg_dialog);
  • // hide non-default categories
  • @@ -1653,8 +1758,8 @@
  • collapse_tag_category(category, true, false);
  • }
  • - function get_thumbnail_post_id(thumb_span) {
  • - const pid = thumb_span.id;
  • + function get_thumbnail_post_id(thumb) {
  • + const pid = thumb.id;
  • if (typeof pid === 'undefined') return null; // first thumbnail on similar page doesn't have post id
  • if (typeof pid !== 'string' || !pid.startsWith('p')) {
  • console.error('[addon error] invalid thumbnail id');
  • @@ -1670,14 +1775,52 @@
  • return id;
  • }
  • - // should be equivalent to unsafeWindow.Post.posts.get(get_thumbnail_post_id(thumb_span)).match_tags,
  • - // except unsafeWindow.Post might not yet be loaded.
  • - function get_thumbnail_tags(thumb_span) {
  • - const img = thumb_span.querySelector('.preview');
  • + const post_cache = new Map(); // returns object with tags and rating
  • + function get_post(post_id) {
  • + // might not be loaded yet or exist at all (e.g on deletion page)
  • + let post = unsafeWindow.Post?.posts?.get(post_id);
  • + if (post !== undefined) return post;
  • +
  • + post = post_cache.get(post_id);
  • + if (post !== undefined) return post;
  • +
  • + return null;
  • + }
  • +
  • + function get_post_from_thumb(thumb) {
  • + const img = thumb.querySelector('.preview');
  • if (img === null) return null;
  • - const tags = img.title.trim().split(/\s+/);
  • - return tags;
  • + const post_id = get_thumbnail_post_id(thumb);
  • + let post = get_post(get_thumbnail_post_id(thumb));
  • +
  • + if (post !== null) return post;
  • +
  • + // note: thumbnail title tags are slightly different, e.g. Rating:Safe instead of rating:s
  • + let tags = img.title.trim().split(/\s+/);
  • +
  • + // find rating
  • + let rating = null;
  • + for (const tag of tags) {
  • + if (tag.startsWith('Rating:')) {
  • + rating = tag.substring('Rating:'.length)[0].toLowerCase();
  • + break;
  • + }
  • + }
  • +
  • + // remove "match tags"
  • + tags = tags.filter((tag) => {
  • + return !(tag.startsWith('Rating:') || tag.startsWith('Score:') || tag.startsWith('Size:') || tag.startsWith('User:'));
  • + });
  • +
  • + post = {
  • + tags,
  • + rating,
  • + };
  • +
  • + post_cache.set(post_id, post);
  • +
  • + return post;
  • }
  • function modify_thumbnails(root) {
  • @@ -1687,14 +1830,10 @@
  • for (const thumb of root.getElementsByClassName('thumb')) {
  • const id = get_thumbnail_post_id(thumb);
  • - if (id === null) {
  • - show_notice(console.error, '[addon error] invalid thumbnail id?!');
  • - return;
  • - }
  • -
  • + if (id === null) return;
  • // use and update thumbnail_cache
  • - let thumbs = thumbnail_cache.get(id);
  • - if (thumbs === undefined) thumbs = [];
  • + const thumbs = thumbnail_cache.get(id) ?? [];
  • const is_new = !thumbs.includes(thumb);
  • @@ -1702,8 +1841,7 @@
  • thumbnail_cache.set(id, thumbs);
  • if (is_new) {
  • - add_speaker_icon(thumb);
  • - add_ratings_icon(thumb);
  • + add_thumbnail_icons(thumb);
  • add_thumbnail_click_listener(thumb);
  • if (!is_personal_post_page())
  • @@ -1712,89 +1850,50 @@
  • }
  • }
  • - function add_thumbnail_click_listener(thumb_span) {
  • - const a = thumb_span.querySelector('a');
  • - if (a === null) return;
  • -
  • - a.addEventListener('click', (e) => {
  • + function add_thumbnail_click_listener(thumb) {
  • + thumb.querySelector('a')?.addEventListener('click', (e) => {
  • thumbnail_click_listener(e.currentTarget.parentElement);
  • });
  • }
  • - function add_speaker_icon(thumb_span) {
  • - if (!(config.show_speaker_icon || config.show_animated_icon)) return;
  • + function add_thumbnail_icons(thumb) {
  • + if (!(config.show_speaker_icon || config.show_animated_icon || config.show_ratings_icon)) return;
  • - const a = thumb_span.querySelector('a');
  • + const a = thumb.querySelector('a');
  • if (a === null) return;
  • - const tags = get_thumbnail_tags(thumb_span);
  • - if (tags === null) return;
  • -
  • - const icon = document.createElement('SPAN');
  • - if (config.show_speaker_icon && (tags.includes('has_audio'))) {
  • - icon.innerText = '🔊';
  • - } else if (config.show_animated_icon && (tags.includes('animated') || tags.includes('video') || tags.includes('slideshow'))) {
  • - 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.style.display = 'inline-block'; // makes the element fit its content
  • - a.style.position = 'relative';
  • - a.appendChild(icon);
  • - }
  • -
  • + const post = get_post_from_thumb(thumb);
  • + if (post == null) return;
  • +
  • - function add_ratings_icon(thumb_span) {
  • - if (!config.show_ratings_icon) return;
  • + const icons = document.createElement('SPAN');
  • + icons.style.whiteSpace = 'nowrap';
  • - const a = thumb_span.querySelector('a');
  • - if (a === null) return;
  • -
  • - const tags = get_thumbnail_tags(thumb_span);
  • - if (tags === null) return;
  • + if (config.show_ratings_icon) {
  • + icons.insertAdjacentHTML('beforeend', RATING_SVG[post.rating]);
  • + }
  • - const icon = document.createElement('SPAN');
  • - if (tags.includes('Rating:Explicit')) {
  • - icon.innerText = 'E';
  • - icon.style.color = '#E00';
  • - } else if (tags.includes('Rating:Questionable')) {
  • - icon.innerText = 'Q';
  • - icon.style.color = '#666';
  • - } else if (tags.includes('Rating:Safe')) {
  • - icon.innerText = 'S';
  • - icon.style.color = '#4dc919';
  • - } else {
  • - return;
  • + if (config.show_speaker_icon && (post.tags.includes('has_audio'))) {
  • + icons.insertAdjacentHTML('beforeend', SPEAKER_SVG);
  • + } else if (config.show_animated_icon && (post.tags.includes('animated') || post.tags.includes('video') || post.tags.includes('slideshow'))) {
  • + icons.insertAdjacentHTML('beforeend', ANIMATED_SVG);
  • }
  • - icon.className = 'ratings_icon';
  • - icon.style.position = 'absolute';
  • - icon.style.bottom = '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%)';
  • + icons.className = 'thumbnail_icons';
  • + icons.style.position = 'absolute';
  • + icons.style.top = '2px'; // account for border
  • + icons.style.right = '2px';
  • + icons.style.transform = `translateX(${SVG_SIZE / 2}px) translateY(-${SVG_SIZE / 2}px)`;
  • a.style.display = 'inline-block'; // makes the element fit its content
  • a.style.position = 'relative';
  • - a.appendChild(icon);
  • + a.appendChild(icons);
  • }
  • - // TODO this isn't optimal when the thumbnail has low contrast to the background
  • - function fadeout_post(thumb_span) {
  • + function fadeout_post(thumb) {
  • if (!config.view_history_enabled) return;
  • - const a = thumb_span.querySelector('a');
  • - const img = thumb_span.querySelector('img');
  • + const a = thumb.querySelector('a');
  • + const img = thumb.querySelector('img');
  • // move box shadow from image to link, so opacity doesn't affect it
  • a.style.display = 'inline-block';
  • @@ -1802,13 +1901,13 @@
  • img.style.removeProperty('box-shadow');
  • img.style.opacity = '20%';
  • - for (const speaker_icon of thumb_span.getElementsByClassName('speaker_icon'))
  • + for (const speaker_icon of thumb.getElementsByClassName('speaker_icon'))
  • speaker_icon.style.opacity = '20%';
  • }
  • - function fadeout_viewed_post(thumb_span, id) {
  • + function fadeout_viewed_post(thumb, id) {
  • if (config[HISTORY_KEY].has(id))
  • - fadeout_post(thumb_span);
  • + fadeout_post(thumb);
  • }
  • function add_thumbnail_observer(predicate) {
  • @@ -1831,6 +1930,40 @@
  • node.controls = config.video_controls;
  • }
  • + function useful_beta_link(pathname) { // idea and some code donated by Octopus Hugger
  • + const params = new URL(window.location.href).searchParams;
  • + const betaLink = new URL('https://beta.sankakucomplex.com/');
  • +
  • + // normalize post index
  • + if (pathname.startsWith('/post/index')) pathname = '/';
  • +
  • + // post index/page, simply replace chan with beta and keep search parameters
  • + if (pathname === '/' || pathname.startsWith('/post/show')) {
  • + betaLink.pathname = pathname;
  • + betaLink.search = window.location.search;
  • + }
  • +
  • + // user page, /user/show/<id> -> /user/show?id=<id>
  • + if (pathname.startsWith('/user/show')) {
  • + const temp = (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
  • + const user_id = temp.substring(temp.lastIndexOf('/') + 1);
  • +
  • + betaLink.pathname = '/user/show';
  • + betaLink.searchParams.set('id', user_id);
  • + }
  • +
  • + // wiki, /wiki/show?title=<entry> -> /tag/en?tagName=<entry>
  • + if (pathname.startsWith('/wiki/show')) {
  • + betaLink.pathname = '/tag/en';
  • + betaLink.searchParams.set('tagName', params.get('title'));
  • + }
  • +
  • + // update beta link
  • + for (const a of document.querySelectorAll('#navbar a[href="https://beta.sankakucomplex.com/"]')) {
  • + a.href = betaLink.href;
  • + }
  • + }
  • +
  • /***********************************************/
  • /* main page / visually similar page functions */
  • @@ -1916,14 +2049,17 @@
  • <img id="SA-edit-image" src="${EMPTY_IMAGE}">
  • </div>
  • <div>
  • +<h5><a id="post-link" href="" target="_blank"></a></h5>
  • <form id="SA-edit-form" method="post" style="width: fit-content">
  • <input id="SA-post_old_tags" name="post[old_tags]" type="hidden" value="">
  • <table class="form">
  • <tfoot>
  • <tr>
  • -<td colspan="2"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
  • +<td colspan="2" style="white-space: nowrap"><input accesskey="s" name="commit" tabindex="11" type="submit" value="Save changes" style="min-width: 13.5em;">
  • <button id="tag_reset_button" style="margin-left: 6px; cursor: pointer;">Reset</button>
  • -<button id="post-link" style="cursor: pointer;">Open Post</button>
  • +<button id="tag_dup_button" style="cursor: pointer;">+duplicate</button>
  • +<button id="tag_var_button" style="cursor: pointer;">+legitimate_variation</button>
  • +<button id="tag_pot_button" style="cursor: pointer;">+potential_duplicate</button>
  • </td>
  • </tr>
  • </tfoot>
  • @@ -1946,7 +2082,7 @@
  • <label class="block" for="SA-post_tags">Tags</label>
  • </th>
  • <td>
  • -<textarea cols="50" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off"></textarea>
  • +<textarea cols="82" id="SA-post_tags" name="post[tags]" rows="9" spellcheck="false" tabindex="10" autocomplete="off"></textarea>
  • </td>
  • </tr>
  • </tbody>
  • @@ -1983,23 +2119,20 @@
  • function is_post_edit_dialog_visible() {
  • const dialog = document.getElementById('post_edit_dialog');
  • - return dialog !== null && dialog.style.display === 'block';
  • + return dialog?.style.display === 'block';
  • }
  • - function open_post_edit_dialog(thumb_span) {
  • + function open_post_edit_dialog(thumb) {
  • const dialog = document.getElementById('post_edit_dialog');
  • if (dialog === null) {
  • show_notice(console.error, '[addon error] tag edit popup is missing?!');
  • return;
  • }
  • - const post_id = get_thumbnail_post_id(thumb_span);
  • - if (post_id === null) {
  • - show_notice(console.error, '[addon error] invalid thumbnail id?!');
  • - return;
  • - }
  • -
  • + const post_id = get_thumbnail_post_id(thumb);
  • + if (post_id === null) return;
  • - const a = thumb_span.querySelector('a');
  • + const a = thumb.querySelector('a');
  • if (a === null) return;
  • const edit_image = document.getElementById('SA-edit-image');
  • @@ -2010,16 +2143,14 @@
  • // set thumbnail image and post link
  • edit_image.src = EMPTY_IMAGE;
  • - edit_image.src = thumb_span.querySelector('.preview').src;
  • - post_link.onclick = () => {
  • - open_in_tab(a.href);
  • - return false;
  • - };
  • + edit_image.src = thumb.querySelector('.preview').src;
  • + post_link.href = a.href;
  • + post_link.innerText = 'Post ' + post_id;
  • const full_rating = (rating) => ({ s: 'safe', q: 'questionable', e: 'explicit' }[rating]);
  • - // get tags and rating (don't use get_thumbnail_tags() here because of possible browser extension conflicts)
  • - const post = unsafeWindow.Post.posts.get(post_id);
  • + // get tags and rating
  • + const post = get_post_from_thumb(thumb);
  • const tags = post.tags.join(' ');
  • const rating = full_rating(post.rating);
  • @@ -2031,9 +2162,14 @@
  • document.getElementById('SA-post_rating_' + rating).checked = true;
  • }
  • - form.addEventListener('submit', (event) => {
  • + update_tag_elements();
  • +
  • + form.onsubmit = (event) => {
  • event.preventDefault(); // block reloading page
  • + const submitted_tags = post_tags.value.trim().split(/\s+/);
  • + show_notice(console.log, '[addon] saving...');
  • +
  • // manually submit data
  • fetch(`/post/update/${post_id}`, {
  • method: 'POST',
  • @@ -2043,7 +2179,11 @@
  • .then((result) => {
  • // we assume success on redirect
  • if (result.type === 'opaqueredirect' || result.ok) {
  • - show_post_edit_dialog(false);
  • + // update local tags
  • + post.tags = submitted_tags;
  • + deletion_sanity_checks();
  • +
  • + show_notice(console.log, '[addon] saved tags!');
  • } else {
  • show_notice(console.error, '[addon error] couldn\'t save tags!');
  • }
  • @@ -2051,7 +2191,7 @@
  • .catch((error) => {
  • show_notice(console.error, '[addon error] network error while saving tags!', error);
  • });
  • - });
  • + };
  • show_post_edit_dialog(true);
  • }
  • @@ -2059,20 +2199,27 @@
  • function add_postmode_hotkeys() {
  • document.addEventListener('keydown', (e) => {
  • const mode_dropdown = document.getElementById('mode');
  • + const script_presets = document.getElementById('tagscript_presets_dropdown');
  • if (mode_dropdown === null) return;
  • if (e.ctrlKey || e.altKey || e.shiftKey) return;
  • - if (e.target === mode_dropdown) {
  • + if (e.target === mode_dropdown || (script_presets !== null && e.target === script_presets)) {
  • 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;
  • - }
  • -
  • + } else if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) {
  • + return;
  • }
  • const old_mode = mode_dropdown.value;
  • + // allow to quickly apply a tagscript preset
  • + if ('0' <= e.key && e.key <= '9') {
  • + const script_index = e.key === '0' ? 10 : Number(e.key);
  • + const dropdown = document.getElementById('tagscript_presets_dropdown');
  • + dropdown.selectedIndex = script_index;
  • + dropdown.dispatchEvent(new Event('change'));
  • + return;
  • + }
  • +
  • switch (e.key) {
  • case 'v':
  • if (IS_GREASEMONKEY4) {
  • @@ -2610,8 +2757,8 @@
  • }
  • function add_tags_change_listener() {
  • - const post_tags_area = document.getElementById('post_tags');
  • - if (post_tags_area === null) return; // not logged in
  • + const post_tags = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • + if (post_tags === null) return; // not logged in
  • const delayed_update = () => {
  • tags_changed = true;
  • @@ -2619,8 +2766,8 @@
  • tag_update_timer = setTimeout(update_tag_elements, 500);
  • };
  • - post_tags_area.addEventListener('change', delayed_update);
  • - post_tags_area.addEventListener('input', delayed_update);
  • + post_tags.addEventListener('change', delayed_update);
  • + post_tags.addEventListener('input', delayed_update);
  • }
  • // also used for 'tag_menu_save' button
  • @@ -2806,40 +2953,45 @@
  • }
  • 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)
  • + const taglist = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • + if (taglist === 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';
  • + const dup_button = document.getElementById('tag_dup_button');
  • + if (dup_button !== null) {
  • + if (!tags.includes('duplicate')) {
  • + dup_button.onclick = function() { add_tag('duplicate'); return false; };
  • + dup_button.innerText = '+duplicate';
  • + } else {
  • + dup_button.onclick = function() { remove_tag('duplicate'); return false; };
  • + dup_button.innerText = '-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';
  • + const var_button = document.getElementById('tag_var_button');
  • + if (var_button !== null) {
  • + if (!tags.includes('legitimate_variation')) {
  • + var_button.onclick = function() { add_tag('legitimate_variation'); return false; };
  • + var_button.innerText = '+legitimate_variation';
  • + } else {
  • + var_button.onclick = function() { remove_tag('legitimate_variation'); return false; };
  • + var_button.innerText = '-legitimate_variation';
  • + }
  • +
  • }
  • - pot_button.innerText = 'Untag potential_duplicate';
  • - if (!tags.includes('potential_duplicate')) {
  • - pot_button.disabled = true;
  • - pot_button.style.removeProperty('cursor');
  • - } else {
  • - pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; };
  • - pot_button.disabled = false;
  • - pot_button.style.cursor = 'pointer';
  • + const pot_button = document.getElementById('tag_pot_button');
  • + if (pot_button !== null) {
  • + pot_button.innerText = '-potential_duplicate';
  • + if (!tags.includes('potential_duplicate')) {
  • + pot_button.disabled = true;
  • + pot_button.style.removeProperty('cursor');
  • + } else {
  • + pot_button.onclick = function() { remove_tag('potential_duplicate'); return false; };
  • + pot_button.disabled = false;
  • + pot_button.style.cursor = 'pointer';
  • + }
  • +
  • }
  • }
  • @@ -2848,14 +3000,17 @@
  • }
  • function get_old_tags_array() {
  • - return document.getElementById('post_old_tags').value.trim().split(/\s+/);
  • + const post_old_tags = document.getElementById('post_old_tags') ?? document.getElementById('SA-post_old_tags');
  • + return post_old_tags.value.trim().split(/\s+/);
  • }
  • function get_tags_array() {
  • - return document.getElementById('post_tags').value.trim().split(/\s+/);
  • + const post_tags = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • + return post_tags.value.trim().split(/\s+/);
  • }
  • function add_tag(tag, skip_common_tags_update = false) {
  • + const post_tags = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • const tags = get_tags_array();
  • if ((tag === 'duplicate' && tags.includes('legitimate_variation')) || (tag === 'legitimate_variation' && tags.includes('duplicate'))) {
  • @@ -2868,20 +3023,21 @@
  • return;
  • }
  • - document.getElementById('post_tags').value += ' ' + tag;
  • + post_tags.value += ' ' + tag;
  • tags_changed = true;
  • update_tag_elements(skip_common_tags_update);
  • }
  • function remove_tag(tag, skip_common_tags_update = false) {
  • + const post_tags = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • 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();
  • + post_tags.value = tags.join(' ').trim();
  • tags_changed = true;
  • update_tag_elements(skip_common_tags_update);
  • @@ -2896,7 +3052,9 @@
  • }
  • function reset_tags() {
  • - document.getElementById('post_tags').value = document.getElementById('post_old_tags').value;
  • + const post_tags = document.getElementById('post_tags') ?? document.getElementById('SA-post_tags');
  • + const post_old_tags = document.getElementById('post_old_tags') ?? document.getElementById('SA-post_old_tags');
  • + post_tags.value = post_old_tags.value;
  • tags_changed = false;
  • update_tag_elements();
  • }
  • @@ -3666,13 +3824,51 @@
  • if (thumbs.length !== 2)
  • return; // no parent
  • - const checked_tags = ['upscaled', 'legitimate_variation', 'revision', 'third-party_edit'];
  • -
  • + const WARNING_TEXT = '<b style="color: crimson">Warning: </b>';
  • + const CHECKED_TAGS = ['upscaled', 'legitimate_variation', 'revision', 'third-party_edit', 'potential_upscale'];
  • +
  • + // remove old warnings before re-evaluating
  • + document.querySelectorAll('.SA-warning').forEach((el) => el.remove());
  • +
  • + const h5 = document.querySelector('#content > .deleting-post > ul > h5');
  • + const res = document.querySelector('#content > .deleting-post > ul > li:nth-child(2)');
  • +
  • + const posts = thumbs.map(get_post_from_thumb);
  • + const warn_tags = posts.map((post) => post.tags.filter((tag) => CHECKED_TAGS.includes(tag)));
  • +
  • + const convert_to_margin = (thumb) => {
  • + const img = thumb.querySelector('.preview');
  • + const thumb_height = window.getComputedStyle(thumb).height;
  • + const img_height = window.getComputedStyle(img).height;
  • +
  • + // the thumbnail image is centered in a grid (see adjust_css()), which will move it up when something is added below it
  • + // to fix this we replace the vertical centering with a margin (and allow the thumbnail to scale vertically too)
  • +
  • + // fix thumbnail in place vertically
  • + img.style.marginTop = `calc((${thumb_height} - ${img_height}) / 2 - 2px)`;
  • + thumb.style.alignContent = 'start';
  • + // make thumbnail scale vertically
  • + thumb.style.minHeight = thumb_height;
  • + thumb.style.height = 'auto';
  • + };
  • +
  • - const thumbs_tags = thumbs.map(get_thumbnail_tags);
  • - const thumb_warn_tags = thumbs_tags.map((tags) => tags.filter((tag) => checked_tags.includes(tag)));
  • + thumbs.forEach(convert_to_margin);
  • const warnings = [];
  • + const add_tags_below_thumb = (thumb, tags, missing) => {
  • + for (const tag of tags) {
  • + const span = document.createElement('SPAN');
  • + span.className = 'SA-warning';
  • + span.style.color = 'crimson';
  • + span.style.fontWeight = 'bold';
  • + if (missing) span.style.textDecoration = 'line-through';
  • + span.innerText = tag;
  • +
  • + thumb.appendChild(span);
  • + }
  • + };
  • +
  • const add_tag_warning = (post, tags) => {
  • if (tags.length === 0) return;
  • @@ -3681,22 +3877,53 @@
  • warnings.push(`${post} has ${taglist} tag${tags.length > 1 ? 's' : ''}`);
  • };
  • - add_tag_warning('child', thumb_warn_tags[0]);
  • - add_tag_warning('parent', thumb_warn_tags[1]);
  • - if (!thumbs_tags[0].includes('duplicate'))
  • + for (let i = 0; i < thumbs.length; i++) {
  • + add_tags_below_thumb(thumbs[i], warn_tags[i]);
  • + }
  • +
  • + add_tag_warning('child', warn_tags[0]);
  • + add_tag_warning('parent', warn_tags[1]);
  • + if (!posts[0].tags.includes('duplicate')) {
  • warnings.push('child isn\'t tagged as <b>duplicate</b>');
  • + add_tags_below_thumb(thumbs[0], ['duplicate'], true);
  • + }
  • - const h5 = document.querySelector('.deleting-post > ul > h5');
  • -
  • + const integer_multiple = (a, b) => {
  • + if (a > b && a % b === 0) {
  • + return a / b;
  • + }
  • + return NaN;
  • + };
  • +
  • + const widths = [];
  • + const heights = [];
  • +
  • + // read resolutions
  • + for (const b of res.getElementsByTagName('B')) {
  • + const match = /([\d]+)x([\d]+)/.exec(b.innerText);
  • + if (match) {
  • + const [, width, height] = match;
  • + widths.push(Number(width));
  • + heights.push(Number(height));
  • + }
  • + }
  • +
  • + // add potential upscale warning
  • + const multiple = integer_multiple(...widths);
  • + if (multiple === integer_multiple(...heights)) {
  • + res.insertAdjacentHTML('beforeend', ` ${WARNING_TEXT} potential ${multiple}x upscale`);
  • + }
  • +
  • + // insert tag text warnings (TODO remove?)
  • for (const warning of warnings.reverse()) {
  • const li = document.createElement('LI');
  • - li.insertAdjacentHTML('beforeend', '<b style="color: crimson">Warning: </b>');
  • - li.insertAdjacentHTML('beforeend', warning);
  • + li.className = 'SA-warning';
  • + li.insertAdjacentHTML('beforeend', WARNING_TEXT + warning);
  • h5.insertAdjacentElement('afterend', li);
  • }
  • }
  • - function add_custom_duplicate_delete_reason() { // and sanity checks
  • + function add_custom_duplicate_delete_reason() {
  • const reason = document.getElementById('reason');
  • const custom_reason = document.getElementById('custom_reason');
  • @@ -3744,9 +3971,28 @@
  • tags_not_present.appendChild(button);
  • }
  • + function add_post_edit_buttons() {
  • + // cache thumbnail tags
  • + for (const thumb of document.querySelectorAll('.thumb'))
  • + get_post_from_thumb(thumb);
  • +
  • + const thumbs = [...document.querySelectorAll('.deleting-post .thumb')];
  • + for (const thumb of thumbs) {
  • + const a = document.createElement('A');
  • + a.innerText = '⚙';
  • + a.style.fontSize = '120%';
  • + a.href = '#';
  • + a.onclick = () => {
  • + open_post_edit_dialog(thumb);
  • + return false;
  • + };
  • + thumb.appendChild(a);
  • + }
  • + }
  • +
  • function add_moderation_search_template() {
  • const query = document.getElementById('query');
  • - const select = create_template_dropdown(new Map([['Flagged Posts', '-status:pending']]), (value) => {
  • + const select = create_template_dropdown(new Map([['Pending Posts', 'status:pending'], ['Flagged Posts', '-status:pending']]), (value) => {
  • select.selectedIndex = 0;
  • query.focus();
  • @@ -3869,6 +4115,7 @@
  • update_config_dialog();
  • update_headerlogo();
  • + useful_beta_link(pathname);
  • // process what the thumbnail observer may have missed
  • modify_thumbnails(document);
  • @@ -3882,6 +4129,7 @@
  • if (config.tag_search_buttons) add_tag_search_buttons();
  • add_postmode_hotkeys();
  • add_post_edit_dialog();
  • + update_tag_elements(); // initialize tag menu/buttons
  • if (called_add_mode_options) {
  • // add_mode_options() was called early, as it should
  • @@ -3982,6 +4230,12 @@
  • document.getElementById('custom_reason').style.minWidth = '25%';
  • add_custom_duplicate_delete_reason();
  • add_tags_copy_button();
  • + add_post_edit_dialog();
  • + add_post_edit_buttons();
  • +
  • + update_tag_elements(); // initialize tag menu/buttons
  • + add_tags_change_listener();
  • +
  • deletion_sanity_checks();
  • /*****************/