您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
drag & drop tags into the order you'd like before posting
- // ==UserScript==
- // @name AO3: Reorder Tags with Drag & Drop
- // @namespace https://gf.qytechs.cn/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.gf.qytechs.cn/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);
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址