AO3: [Wrangling] Smaller Tag Search

makes the new Tag Search form take up less space (best on desktop/landscape screens)

目前為 2024-01-27 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3: [Wrangling] Smaller Tag Search
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      3.1
// @description  makes the new Tag Search form take up less space (best on desktop/landscape screens)
// @author       escctrl
// @match        *://*.archiveofourown.org/tags/search*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @grant        none
// @license      MIT
// ==/UserScript==


(function($) {
    'use strict';

    // on retry later, break off the rest of this script to avoid console errors
    if ($('#new_tag_search').length == 0) return;

    /*********************************************************
     GUI CONFIGURATION
     *********************************************************/

    // ASC/DESC translation for the different sort options
    // x -> [ASC, DESC]
    var dir_alias = new Map([["name", ["A → Z", "Z → A"]], ["created_at", ["oldest → newest", "newest → oldest"]], ["uses", ["fewest → most", "most → fewest"]]]);

    // standard config in case nothing was configured so far
    var hide_sort_options = false;
    var hide_labels = false;
    var make_textinput_smaller = false;
    var default_search_type = "";
    var default_search_status = "";
    var default_sort_by = "name";
    var default_sort_dir = "asc";

    // load storage on page startup
    var stored_config = new Map(JSON.parse(localStorage.getItem('smallertagsearch')));
    if (stored_config.size > 0) {
        hide_sort_options = stored_config.get('sorthide') == "true" ? true : false;
        hide_labels = stored_config.get('selectlabels') == "true" ? true : false;
        make_textinput_smaller = stored_config.get('textlabels') == "true" ? true : false;
        default_search_type = stored_config.get('deftype');
        default_search_status = stored_config.get('defstatus');
        default_sort_by = stored_config.get('defsortby');
        default_sort_dir = stored_config.get('defsortdir');
    }

    // if the background is dark, use the dark UI theme to match
    let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "ui-darkness" : "base";

    // the config dialog container
    let cfg = document.createElement('div');
    cfg.id = 'tagSearchDialog';

    // adding the jQuery stylesheet to style the dialog, and fixing the interferance of AO3's styling
    $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
    .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
    .append(`<style tyle="text/css">#${cfg.id}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
    #${cfg.id} form {box-shadow: revert; cursor:auto;}
    #${cfg.id} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
    #${cfg.id} #tagsearchdefault label { display: inline-block; width: 8em; }
    #${cfg.id} #tagsearchdisplay label { width: 16em; text-align: left; }
    #${cfg.id} fieldset {background: revert; box-shadow: revert;}
    </style>`);

    var selected = 'selected="selected"';
    var checked = 'checked="checked"';
    var default_sort = default_sort_by + "-" + default_sort_dir;

    // helper function to create a dropdown for tag types with the correct preselected option
    function buildTypeOptions(compare) {
        let options = "";
        $(["", "Fandom", "Character", "Relationship", "Freeform", "UnsortedTag"]).each( function() {
            options += `<option value="${this}" ${(compare == this) ? selected : ""}>${
                (this == "") ? "Any tag type" :
                (this == "UnsortedTag") ? "Unsorted Tags" : this
            }</option>`;
        });
        return options;
    }

    $(cfg).html(`<form>
    <fieldset id='tagsearchdisplay'>
        <legend>Display</legend>
        <p>The script already turns radiobuttons into selection lists. Choose what else to hide to make the tag search form smaller.</p>
        <label for="selectlabels">Hide Labels on Selection Lists</label><input type="checkbox" name="selectlabels" id="selectlabels" ${(hide_labels) ? checked : ""}><br />
        <label for="sorthide">Hide Sort Options</label><input type="checkbox" name="sorthide" id="sorthide" ${(hide_sort_options) ? checked : ""}><br />
        <label for="textlabels">Hide Labels on Textfields</label><input type="checkbox" name="textlabels" id="textlabels" ${(make_textinput_smaller) ? checked : ""}>
    </fieldset>
    <fieldset id='tagsearchdefault'>
        <legend>Default Selections</legend>
        <p>Pick defaults for the tag type, wrangling status, and sort order.</p>
        <label for="deftype">Tag Type</label>
        <select name="deftype" id="deftype">
          ${buildTypeOptions(default_search_type)}
        </select><br />
        <label for="defstatus">Wrangling Status</label>
        <select name="defstatus" id="defstatus">
          <option value="" ${(default_search_status == "") ? selected : ""}>Any wrangling status</option>
          <option value="Canonical" ${(default_search_status == "Canonical") ? selected : ""}>Canonical</option>
          <option value="Non-canonical" ${(default_search_status == "Non-canonical") ? selected : ""}>Non-canonical</option>
        </select>
        <label for="defsort">Sort By</label>
        <select name="defsort" id="defsort">
          <option value="name-asc" ${default_sort == "name-asc" ? selected : ""}>Name, ${dir_alias.get('name')[0]}</option>
          <option value="name-desc" ${default_sort == "name-desc" ? selected : ""}>Name, ${dir_alias.get('name')[1]}</option>
          <option value="created_at-asc" ${default_sort == "created_at-asc" ? selected : ""}>Creation Date, ${dir_alias.get('created_at')[0]}</option>
          <option value="created_at-desc" ${default_sort == "created_at-desc" ? selected : ""}>Creation Date, ${dir_alias.get('created_at')[1]}</option>
          <option value="uses-asc" ${default_sort == "uses-asc" ? selected : ""}>Uses, ${dir_alias.get('uses')[0]}</option>
          <option value="uses-desc" ${default_sort == "uses-desc" ? selected : ""}>Uses, ${dir_alias.get('uses')[1]}</option>
        </select>
    </fieldset>
    </form>`);

    // attach it to the DOM so that selections work (but only if #main exists, else it might be a Retry Later error page)
    if ($("#main").length == 1) $("body").append(cfg);

    // optimizing the size of the GUI in case it's a mobile device
    let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
    dialogwidth = dialogwidth > 500 ? 500 : dialogwidth * 0.9;

    // initialize the dialog (but don't open it)
    $( "#tagSearchDialog" ).dialog({
        appendTo: "#main",
        modal: true,
        title: 'Smaller Tag Search Config',
        draggable: true,
        resizable: false,
        autoOpen: false,
        width: dialogwidth,
        position: {my:"center", at: "center top"},
        buttons: {
            Reset: deleteConfig,
            Save: storeConfig,
            Cancel: function() { $( "#tagSearchDialog" ).dialog( "close" ); }
        }
    });

    // event triggers if form is submitted with the <enter> key
    $( "#tagSearchDialog form" ).on("submit", (e) => {
        e.preventDefault();
        storeConfig();
    });

    function deleteConfig() {
        // deselects all buttons, empties all fields in the form
        $('#tagSearchDialog form').trigger("reset");

        // deletes the localStorage
        localStorage.removeItem('smallertagsearch');

        $( "#tagSearchDialog" ).dialog( "close" );
        location.reload();
    }

    function storeConfig() {
        // build a Map() for enabled standard buttons => button -> true/false
        let storestd = new Map();
        $( "#tagSearchDialog input[name]" ).each(function() { storestd.set( $(this).prop('name'), String($(this).prop('checked')) ); });
        $( "#tagSearchDialog select[name]" ).each(function() {
            if ($(this).prop('name') == "defsort") {
                let val = $(this).prop('value').split('-');
                storestd.set( 'defsortby', val[0] );
                storestd.set( 'defsortdir', val[1] );
            }
            else
                storestd.set( $(this).prop('name'), String($(this).prop('value')) );
        });
        localStorage.setItem('smallertagsearch', JSON.stringify(Array.from(storestd.entries())));

        $( "#tagSearchDialog" ).dialog( "close" );
        location.reload();
    }

    /* CREATING THE LINK TO OPEN THE CONFIGURATION DIALOG */

    // if no other script has created it yet, write out a "Userscripts" option to the main navigation
    if ($('#scriptconfig').length == 0) {
        $('#header ul.primary.navigation li.dropdown').last()
            .after(`<li class="dropdown" id="scriptconfig">
                <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
                <ul class="menu dropdown-menu"></ul></li>`);
    }
    // then add this script's config option to navigation dropdown
    $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_tagsearch">Smaller Tag Search</a></li>`);

    // on click, open the configuration dialog
    $("#opencfg_tagsearch").on("click", function(e) {
        $( "#tagSearchDialog" ).dialog('open');

        // turn checkboxes and radiobuttons into pretty buttons (must be after 'open' for dropdowns to work)
        $( "#tagSearchDialog input[type='checkbox'], #tagSearchDialog input[type='radio']" ).checkboxradio();
        $( "#tagSearchDialog select" ).selectmenu();
    });


    /*********************************************************
     CHANGING THE DISPLAY ON THE PAGE
     *********************************************************/

    // ** 1 ** Display the Type and Wrangling Status choices as dropdowns again to save screen space

    // switch default_search_status config to values AO3 recognizes
    switch (default_search_status) {
        case "Canonical":
            default_search_status = "T";
            break;
        case "Non-canonical":
            default_search_status = "F";
            break;
        default:
            default_search_status = "";
            break;
    }

    // check URL parameters so we can set the correct selected="selected" option from the last search
    var searchParams = new URLSearchParams(window.location.search);
    var search_type = searchParams.has('tag_search[type]') ? searchParams.get('tag_search[type]') : default_search_type;
    var search_canonical = searchParams.has('tag_search[canonical]') ? searchParams.get('tag_search[canonical]') : default_search_status;
    var search_sortby = searchParams.has('tag_search[sort_column]') ? searchParams.get('tag_search[sort_column]') : default_sort_by;
    var search_sortdir = searchParams.has('tag_search[sort_direction]') ? searchParams.get('tag_search[sort_direction]') : default_sort_dir;

    // choose the default sorting
    $("#tag_search_sort_column").val(search_sortby);
    $("#tag_search_sort_direction").val(search_sortdir);

    // create new dropdown for tag types (and add descriptor on "Any" option if labels are hidden)
    var type_select = `<select id="tag_search_type" name="tag_search[type]">${buildTypeOptions(search_type)}</select>`;

    // create new dropdown for tag wrangling status (and add descriptor on "Any" option if labels are hidden)
    var status_select = '<select id="tag_search_status" name="tag_search[canonical]">'
    + '<option value="T"' + (search_canonical == "T" ? selected : "") + '>Canonical</option>'
    + '<option value="F"' + (search_canonical == "F" ? selected : "") + '>Non-canonical</option>'
    + '<option value=""' + (search_canonical == "" ? selected : "") + '>Any wrangling status</option>'
    + '</select>';

    // add in new dropdowns
    var searchform = $("#new_tag_search dl dd fieldset");
    $(searchform).first().before(type_select);
    $(searchform).last().before(status_select);

    // wrap tag type and status labels in a <label> element, so they behave the same as the sorting labels
    $(searchform).first().parent().prev().wrapInner('<label for="tag_search_type"></label>');
    $(searchform).last().parent().prev().wrapInner('<label for="tag_search_status"></label>');

    // remove the radiobuttons at last
    $(searchform).remove();

    // ** 2 ** hide the description below the fandom field
    $("#fandom-field-description").hide();

    // ** 3 ** Hide sort options - or make the ASC/DESC make more sense
    if (hide_sort_options) {
        $("#tag_search_sort_column").parent().hide().prev().hide();
        $("#tag_search_sort_direction").parent().hide().prev().hide();
    }
    else {
        // on page load: align the asc/desc text with what we're sorting by
        $("#tag_search_sort_direction [value='asc']").text(dir_alias.get($("#tag_search_sort_column").val())[0]);
        $("#tag_search_sort_direction [value='desc']").text(dir_alias.get($("#tag_search_sort_column").val())[1]);
        // now the same for when a different sort is chosen
        $("#tag_search_sort_column").on('change', function(e) {
            $("#tag_search_sort_direction [value='asc']").text(dir_alias.get($(e.target).val())[0]);
            $("#tag_search_sort_direction [value='desc']").text(dir_alias.get($(e.target).val())[1]);
            if (hide_labels) $("#tag_search_sort_direction option").prepend("List ");
        });
    }

    // ** 4 ** Reduce label widths and hide those of the dropdowns
    $("#tag_search_status").parent().prev().find('label').html('Status');

    var style = $('<style type="text/css"></style>').appendTo($('head'));
    style.html(`.tagsearch-label { width: 12%; min-width: unset; } .tagsearch-select { width: 20%; margin-left: 0.2em; margin-right: 0.2em; }
                #new_tag_search p.submit.actions { margin-top: -4em; } /* moves Search button up into the dropdown line */
                .tagsearch-select select { min-width: unset; } /* this avoids labels overlapping on zoom */
                .tagsearch-floatlabel { width: 15%; clear: none; margin-left: 2%; } .tagsearch-input { width: 45%; margin-right: 2em; }  `);

    // add classes to all the dds and dts
    $("#new_tag_search dl dt").addClass("tagsearch-label");
    $("#new_tag_search dl dd select").parent().addClass("tagsearch-select");

    // no labels: all shown dropdowns move into a single line
    var search_labels = $(".tagsearch-label");
    if (hide_labels) {
        $(search_labels).slice(2).hide();
        // while we're here, add descriptors to the sort options (if shown)
        if (!hide_sort_options) {
            $("#tag_search_sort_column option").prepend("Sort by ");
            $("#tag_search_sort_direction option").prepend("List ");
        }
    }
    // with labels: float the even-numbered labels to build two columns of dropdowns
    else if (hide_sort_options) {
            $(search_labels).slice(3,4).addClass("tagsearch-floatlabel");
            $(search_labels).slice(5,6).addClass("tagsearch-floatlabel");
    }
    // when all four dropdowns are shown, change the order so the tag type and status are on top of each other
    else {
        var selects = $('#new_tag_search dl').children();
        var moving = selects.slice(-6,-4);
        $(selects).slice(-6, -4).remove();
        $(selects).slice(-2, -1).before(moving);
        $(selects).slice(-4,-3).addClass("tagsearch-floatlabel");
        $(selects).slice(-2,-1).addClass("tagsearch-floatlabel");
    }

    // ** 5 ** Go extreme - also make the text fields smaller
    if (make_textinput_smaller) {

        // hide the labels
        $(search_labels).slice(0,2).hide();

        // add placeholder text to recognize the fields instead of the labels
        $("#tag_search_name").attr("placeholder", "Enter search term");
        $("#tag_search_fandoms_autocomplete").attr("placeholder", "Choose a fandom");

        // make textfields narrower to fit into a single line
        $("#tag_search_name").parent().addClass("tagsearch-input");
        var searchfandom = $("#tag_search_fandoms_autocomplete").parentsUntil("dd").parent().slice(0,1);
        $(searchfandom).addClass("tagsearch-input");
        // fandom search elements need to lose some margin to make it line up with the tag name textfield
        $(searchfandom).find("li.input").css("margin", "0");
    }
    
    // ** 6 ** jump the focus to the text field if there was no search yet
    if (searchParams == "") {
        document.getElementById('tag_search_name').focus();
    }

})(jQuery);

// helper function to determine whether a color (the background in use) is light or dark
// https://awik.io/determine-color-bright-dark-using-javascript/
function lightOrDark(color) {
    var r, g, b, hsp;
    if (color.match(/^rgb/)) { color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
        r = color[1]; g = color[2]; b = color[3]; }
    else { color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&'));
        r = color >> 16; g = color >> 8 & 255; b = color & 255; }
    hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );
    if (hsp>127.5) { return 'light'; } else { return 'dark'; }
}