AO3: Reorder Tags with Drag & Drop

drag & drop tags into the order you'd like before posting

// ==UserScript==
// @name         AO3: Reorder Tags with Drag & Drop
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      2.2
// @description  drag & drop tags into the order you'd like before posting
// @author       escctrl
// @match        https://*.archiveofourown.org/works/new
// @match        https://*.archiveofourown.org/works/*/edit
// @match        https://*.archiveofourown.org/works/*/edit_tags
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// @license      MIT
// ==/UserScript==

(function($) {
    'use strict';

    /* ********* COLORS CONFIGURATION *************** */
    var fixed_stripe1 = "";
    var fixed_stripe2 = "";
    var sortable_border = "";
    var sortable_fill = "";


    // enabling the handle for sorting (workaround for touch devices)
    // configurable just in case someone wants to disable it to save space by removing the handle
    var mobile = true;

    // Iconify
    $("head").prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
             .append(`<style type="text/css"> .reorder-delete, .reorder-copy, .ui-sortable li.reorder .mobile { font-family: FontAwesome, sans-serif; } </style>`);

    // setting defaults if user didn't override them all
    if (fixed_stripe1 == "" || fixed_stripe2 == "" || sortable_border == "" || sortable_fill == "") {
        // official site skins are <link>'ed, default with various medias, others with media="all"; user site skins are inserted directly with <style>
        var skin = $('link[href^="/stylesheets/skins/"][media="all"]');
        // default site skin means there's no media="all", for others there should be exactly one
        if (skin.length == 1) skin = $(skin).attr("href").match(/skin_(\d+)_/);
        skin = skin[1] || "";
        switch (skin) {
            case "929": // reversi
                if (fixed_stripe1 == "") fixed_stripe1 = "#4a1919";
                if (fixed_stripe2 == "") fixed_stripe2 = "#3b1010";
                if (sortable_border == "") sortable_border = "#15390e";
                if (sortable_fill == "") sortable_fill = "#204a18";
                break;
            case "932": // snow blue
            case "928": // the blues
                if (fixed_stripe1 == "") fixed_stripe1 = "#cfd3e6";
                if (fixed_stripe2 == "") fixed_stripe2 = "#d9daeb";
                if (sortable_border == "") sortable_border = "#fff";
                if (sortable_fill == "") sortable_fill = "#cde1d2";
                break;
            case "891": // low vision
            default: // default site skin
                if (fixed_stripe1 == "") fixed_stripe1 = "#e6cfcf";
                if (fixed_stripe2 == "") fixed_stripe2 = "#ebd9d9";
                if (sortable_border == "") sortable_border = "#fff";
                if (sortable_fill == "") sortable_fill = "#cde1d2";
                break; // no changes needed
        }
    }

    // styling the tags so they look more grabbable... kinda inspired by tumblr here
    $(`<style type="text/css">`).appendTo('head').text(`
        /* resetting the some AO3 Widget CSS because it clashes */
        .ui-sortable li { background: none; border: 0px; float: none; width: unset; clear: none; box-shadow: none; display: inline; }
        .ui-sortable li:hover { background: none; border: 0px; cursor: move; box-shadow: none; cursor: auto; }
        .ui-sortable li .autocomplete li { color: #000; } /* needed for reversi autocomplete list */

        /* reducing height or it does weird things jumping around */
        .ui-sortable-placeholder { height: 1px; }

        /* making the fun little rounded corners for everything */
        .ui-sortable li.added.tag, .ui-sortable li.added.tag:hover { margin: 0.1em !important; padding: 0.5em; border-width: 2px; border-style: solid;
            border-radius: 0 15px 0 15px; }

        /* the undraggable, fixed ones */
        .ui-sortable li.fixed, .ui-sortable li.fixed:hover { cursor: auto; border-color: ${fixed_stripe1}; background-size: 25.46px 25.46px;
            background-image: linear-gradient(45deg, ${fixed_stripe1} 22.22%, ${fixed_stripe2} 22.22%, ${fixed_stripe2} 50%, ${fixed_stripe1} 50%, ${fixed_stripe1} 72.22%, ${fixed_stripe2} 72.22%, ${fixed_stripe2} 100%); }

        /* and for the draggable ones */
        .ui-sortable li.reorder, .ui-sortable li.reorder:hover { cursor: auto; border-color: ${sortable_border}; background-image: unset;
            box-shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px; background-color: ${sortable_fill}; }

        /* the handle needs a mousepointer change */
        .ui-sortable-handle, .ui-sortable-handle:hover { cursor: grab; }
        `);

    // on page load: give the ULs an ID so the Widget can work its magic on them
    $('dd.fandom ul.autocomplete').attr('id', 'sortable-fan');
    $('dd.character ul.autocomplete').attr('id', 'sortable-char');
    $('dd.relationship ul.autocomplete').attr('id', 'sortable-rel');
    $('dd.freeform ul.autocomplete').attr('id', 'sortable-ff');

    // on page load: previously added tags are always placed where they used to be by AO3. it refuses to move them. so let's not act like we can.
    // instead, we remember which tags were intially set - those aren't moved, even if the user removes and re-adds them without saving in between
    // Map { "work_fandom"    -> [ 0: "fandom a", 1: "fandom b"... ],
    //       "work_character" -> [0: "char A", 1: "char B"...],       ... }
    const ogTags = new Map();
    ["work_freeform", "work_character", "work_relationship", "work_fandom"].forEach((type) => {
        // this is heavy: grab each tag type group's old <input value="">, split it by comma, trim the tagnames and store them in an Array to the Map
        // value property gets updated on the fly by AO3 JS with unsaved tags on page refresh -> need to use value attribute instead
        var ogTagsGroup = document.getElementById(type).getAttribute('value').split(",").map( (tag) => tag.trim() );
        ogTags.set(type, ogTagsGroup);

        // check all tags on page if they were really saved before. if so, give them a fixed class, otherwise a draggable class
        $(`#${type}`).prev().children("li.added.tag").each((i, e) => {
            if ( ogTagsGroup.includes(getTagText(e)) )
                $(e).addClass('fixed').attr('title', "Sorry, this tag can't be resorted");
            else {
                $(e).addClass('reorder');
                if (mobile) $(e).prepend('<span class="mobile">&#xf0c9; </span>');
            }
        });
    });

    // on pageload: make the tag lists sortable
    $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').sortable({
        items: '> li.added.tag.reorder', // only the draggable LIs
        tolerance: 'pointer', // makes the movement behavior more predictable
        revert: true, // animates reversal when dropped where it can't be sorted
        opacity: 0.5, // transparency for the handle that's being dragged around
        cursor: "grabbing", // switches cursor while dragging a tag for A+ cursor responsiveness
    });
    if (mobile) $( '#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff' ).sortable( "option", "handle", ".mobile" );

    // on user adding items: use .refresh() to make those draggable
    const observer = new MutationObserver(function(mutList, obs) {
        // skip when triggered by the reordering (remove & add)
        // aka if any of the mutations inside it are for a placeholder
        var anyPlaceholder = mutList.find( (m) => (
            Array.from(m.addedNodes).find((n) => n.matches(".ui-sortable-placeholder")) ||
            Array.from(m.removedNodes).find((n) => n.matches(".ui-sortable-placeholder"))
        ));
        if (anyPlaceholder !== undefined) return;

        for (const mut of mutList) {
            for (const node of mut.addedNodes) { // should only ever be one at a time, but better safe than sorry
                obs.disconnect(); // gotta stop watching or our own DOM changes turn this into an infinite loop
                if (node.matches("li.added.tag:not(.ui-sortable-placeholder)")) { // making sure we haven't accidentially seen a different change
                    checkAddedTag(node); // checks if the added tag will actually be sortable by AO3
                    if (mobile && node.matches("li.added.tag.reorder"))
                        $(node).prepend('<span class="mobile">&#xf0c9; </span>');
                    $(node).parent().sortable("refresh");
                }
                startObserving(); // restart observing after our DOM changes are done
            }
        }
    });
    function startObserving() {
        $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').each((i, elem) => {
            observer.observe(elem, { attributes: false, childList: true, subtree: false });
        });
    }
    startObserving();

    function checkAddedTag(n) {
        const nTagText = getTagText(n); // the pure added tag name
        const cTags = Array.from($(n).siblings("li.added.tag")).map((t) => getTagText(t)); // the current list of tags
        const ogTagsGroup = ogTags.get($(n).parent().next().attr("id")); // get the og Tags only for the group where the tag was added

        // CHECK 1: is the added tag a duplicate? AO3 seems to not realize that for anything beyond the first tag in each type
        // problem: the list of current tags already include the added tag, but always as the last one, so ignore that! then we'll find duplicates
        if (cTags.slice(0, -1).includes(nTagText))
            $(n).remove();

        // CHECK 2: is the added tag part of the og list? those still can't be resorted, so we need to put them back in the original order
        else if (ogTagsGroup.includes(nTagText)) {
            // step 1: what was its original index?
            const ogTagPos = ogTagsGroup.indexOf(nTagText);

            // step 2: find a predecessor that's still there
            var predecessorPos = -1;
            for (let i = ogTagPos-1; i >= 0; i--) { // walk backwards through the preceeding og tags
                predecessorPos = cTags.indexOf(ogTagsGroup[i]); // check if this og tag is still in the list (if not: -1)
                if (predecessorPos > -1) break; // stop if we found one
            }

            // step 3a: if no og predecessor was found anymore, move the added tag to the beginning of the current tags
            if (predecessorPos === -1) $(n).prependTo($(n).parent());
            // step 3b: if an og predecessor was found, move the added tag behind it
            else $(n).insertAfter($(n).siblings().eq(predecessorPos));

            // step 4: make it fixed again
            $(n).addClass('fixed').attr('title', "Sorry, this tag can't be resorted");
        }

        // add a class for easier CSS styling in when we're sorting by handle (mobile)
        else $(n).addClass('reorder');
    }

    // on form submit: put everything in the order it's now supposed to be
    $(document).on("submit", function() {
        $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').each((i, elem) => {
            var tags = new Array();
            $(elem).find("li.added.tag").each( (ix, tag) => tags.push(getTagText(tag)) );
            $(elem).next().val(tags.join(","));
        });
    });

    // add copy & delete buttons under each group
    $("dt.fandom, dt.character, dt.relationship, dt.freeform").each((i, me) => {
        $(me).append(`<button type="button" class="reorder-copy" title="Copy ${me.classList[0]} tags to the Clipboard as a comma-separated list">&#xf24d;</button>`)
             .append(`<button type="button" class="reorder-delete" title="Removes all ${me.classList[0]} tags at once">&#xf1f8;</button>`);
    });

    $("button.reorder-copy").on("click", copy2Clipboard);
    $("button.reorder-delete").on("click", deleteTags);

    function copy2Clipboard(e) {
        var str = new Array();
        $(e.target).parent().next().find("li.added.tag").each( (ix, tag) => str.push(getTagText(tag)) );
        str = str.join(",");

        // trying first with the new Clipboard API
        try {
            const clipboardItem = new ClipboardItem({ 'text/plain': new Blob([str], {type: 'text/plain'}) });
            navigator.clipboard.write([clipboardItem]);
        }
        // fallback method in case clipboard.write is not enabled - especially in Firefox it's disabled by default
        // to enable, go to about:config and turn dom.events.asyncClipboard.clipboardItem to true
        catch {
            console.log('Copy Tag to Clipboard: Clipboard API is not enabled in your browser - fallback option used');
            function listener(e) {
                e.clipboardData.setData("text/plain", str);
                e.preventDefault();
            }
            document.addEventListener("copy", listener);
            document.execCommand("copy");
            document.removeEventListener("copy", listener);
        }
    }

    function deleteTags(e) {
        $(e.target).parent().next().find("li.added.tag").remove();
    }

    // helper function since mobile support makes it more complicated
    function getTagText(n) {
        // assuming that n is a LI
        if (mobile && n.matches(".reorder")) return n.childNodes[1].textContent.trim();
        else return n.firstChild.textContent.trim();
    }

})(jQuery);