AO3: Reorder Tags with Drag & Drop

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         AO3: Reorder Tags with Drag & Drop
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      2.4
// @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
// @exclude      https://*.archiveofourown.org/works/*/chapters/*/edit
// @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
// @require      https://update.greasyfork.org/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* global jQuery, copy2Clipboard */

(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;

    // icon SVGs from https://heroicons.com (MIT license Copyright (c) Tailwind Labs, Inc. https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
    const icon_trash  = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" /></svg>`;
    const icon_copy   = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M15.988 3.012A2.25 2.25 0 0 1 18 5.25v6.5A2.25 2.25 0 0 1 15.75 14H13.5V7A2.5 2.5 0 0 0 11 4.5H8.128a2.252 2.252 0 0 1 1.884-1.488A2.25 2.25 0 0 1 12.25 1h1.5a2.25 2.25 0 0 1 2.238 2.012ZM11.5 3.25a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 .75.75v.25h-3v-.25Z" clip-rule="evenodd" /><path fill-rule="evenodd" d="M2 7a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7Zm2 3.25a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Zm0 3.5a.75.75 0 0 1 .75-.75h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" /></svg>`;
    const icon_handle = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75ZM2 10a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 10Zm0 5.25a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z" clip-rule="evenodd" /></svg>`;

    // 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] || "";

    // setting defaults if user didn't override them all
    if (fixed_stripe1 == "" || fixed_stripe2 == "" || sortable_border == "" || sortable_fill == "") {
        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(`
        /* adjusting the svg icons */
        .reorder-delete, .reorder-copy, .ui-sortable li.reorder .mobile { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; }
        .reorder-delete, .reorder-copy { padding: 0.3em; box-sizing: content-box; margin-left: 0.2em }
        .ui-sortable li.reorder .mobile { padding-right: 0.5em; }

        /* 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; }
        /* needed for reversi autocomplete list where suddenly a light text color applies due to .ui-sortable being applied to the outer ul*/
        ${skin === "929" ? ".ui-sortable li .autocomplete li { color: #000; }" : ""}

        /* 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">${icon_handle}</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">${icon_handle}</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">${icon_copy}</button>`)
             .append(`<button type="button" class="reorder-delete" title="Removes all ${me.classList[0]} tags at once">${icon_trash}</button>`);
    });

    // copy tags as a comma-separated list
    $("#work-form").on("click", "button.reorder-copy", function(e) {
        var str = new Array();
        $(this).parent().next().find("li.added.tag").each( (ix, tag) => str.push(getTagText(tag)) );
        str = str.join(",");

        copy2Clipboard(e, "txt", str);
    });

    // delete all tags
    $("#work-form").on("click", "button.reorder-delete", function (e) {
        $(this).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);