AO3: Reorder Tags with Drag & Drop

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

  1. // ==UserScript==
  2. // @name AO3: Reorder Tags with Drag & Drop
  3. // @namespace https://gf.qytechs.cn/en/users/906106-escctrl
  4. // @version 2.4
  5. // @description drag & drop tags into the order you'd like before posting
  6. // @author escctrl
  7. // @match https://*.archiveofourown.org/works/new
  8. // @match https://*.archiveofourown.org/works/*/edit
  9. // @match https://*.archiveofourown.org/works/*/edit_tags
  10. // @exclude https://*.archiveofourown.org/works/*/chapters/*/edit
  11. // @grant none
  12. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
  13. // @require https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
  15. // @require https://update.gf.qytechs.cn/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js
  16. // @license MIT
  17. // ==/UserScript==
  18.  
  19. /* eslint-disable no-multi-spaces */
  20. /* global jQuery, copy2Clipboard */
  21.  
  22. (function($) {
  23. 'use strict';
  24.  
  25. /* ********* COLORS CONFIGURATION *************** */
  26. var fixed_stripe1 = "";
  27. var fixed_stripe2 = "";
  28. var sortable_border = "";
  29. var sortable_fill = "";
  30.  
  31.  
  32. // enabling the handle for sorting (workaround for touch devices)
  33. // configurable just in case someone wants to disable it to save space by removing the handle
  34. var mobile = true;
  35.  
  36. // icon SVGs from https://heroicons.com (MIT license Copyright (c) Tailwind Labs, Inc. https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
  37. 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>`;
  38. 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>`;
  39. 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>`;
  40.  
  41. // official site skins are <link>'ed, default with various medias, others with media="all"; user site skins are inserted directly with <style>
  42. var skin = $('link[href^="/stylesheets/skins/"][media="all"]');
  43. // default site skin means there's no media="all", for others there should be exactly one
  44. if (skin.length == 1) skin = $(skin).attr("href").match(/skin_(\d+)_/);
  45. skin = skin[1] || "";
  46.  
  47. // setting defaults if user didn't override them all
  48. if (fixed_stripe1 == "" || fixed_stripe2 == "" || sortable_border == "" || sortable_fill == "") {
  49. switch (skin) {
  50. case "929": // reversi
  51. if (fixed_stripe1 == "") fixed_stripe1 = "#4a1919";
  52. if (fixed_stripe2 == "") fixed_stripe2 = "#3b1010";
  53. if (sortable_border == "") sortable_border = "#15390e";
  54. if (sortable_fill == "") sortable_fill = "#204a18";
  55. break;
  56. case "932": // snow blue
  57. case "928": // the blues
  58. if (fixed_stripe1 == "") fixed_stripe1 = "#cfd3e6";
  59. if (fixed_stripe2 == "") fixed_stripe2 = "#d9daeb";
  60. if (sortable_border == "") sortable_border = "#fff";
  61. if (sortable_fill == "") sortable_fill = "#cde1d2";
  62. break;
  63. case "891": // low vision
  64. default: // default site skin
  65. if (fixed_stripe1 == "") fixed_stripe1 = "#e6cfcf";
  66. if (fixed_stripe2 == "") fixed_stripe2 = "#ebd9d9";
  67. if (sortable_border == "") sortable_border = "#fff";
  68. if (sortable_fill == "") sortable_fill = "#cde1d2";
  69. break; // no changes needed
  70. }
  71. }
  72.  
  73. // styling the tags so they look more grabbable... kinda inspired by tumblr here
  74. $(`<style type="text/css">`).appendTo('head').text(`
  75. /* adjusting the svg icons */
  76. .reorder-delete, .reorder-copy, .ui-sortable li.reorder .mobile { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; }
  77. .reorder-delete, .reorder-copy { padding: 0.3em; box-sizing: content-box; margin-left: 0.2em }
  78. .ui-sortable li.reorder .mobile { padding-right: 0.5em; }
  79.  
  80. /* resetting the some AO3 Widget CSS because it clashes */
  81. .ui-sortable li { background: none; border: 0px; float: none; width: unset; clear: none; box-shadow: none; display: inline; }
  82. .ui-sortable li:hover { background: none; border: 0px; cursor: move; box-shadow: none; cursor: auto; }
  83. /* needed for reversi autocomplete list where suddenly a light text color applies due to .ui-sortable being applied to the outer ul*/
  84. ${skin === "929" ? ".ui-sortable li .autocomplete li { color: #000; }" : ""}
  85.  
  86. /* reducing height or it does weird things jumping around */
  87. .ui-sortable-placeholder { height: 1px; }
  88.  
  89. /* making the fun little rounded corners for everything */
  90. .ui-sortable li.added.tag, .ui-sortable li.added.tag:hover { margin: 0.1em !important; padding: 0.5em; border-width: 2px; border-style: solid;
  91. border-radius: 0 15px 0 15px; }
  92.  
  93. /* the undraggable, fixed ones */
  94. .ui-sortable li.fixed, .ui-sortable li.fixed:hover { cursor: auto; border-color: ${fixed_stripe1}; background-size: 25.46px 25.46px;
  95. 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%); }
  96.  
  97. /* and for the draggable ones */
  98. .ui-sortable li.reorder, .ui-sortable li.reorder:hover { cursor: auto; border-color: ${sortable_border}; background-image: unset;
  99. 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}; }
  100.  
  101. /* the handle needs a mousepointer change */
  102. .ui-sortable-handle, .ui-sortable-handle:hover { cursor: grab; }
  103. `);
  104.  
  105. // on page load: give the ULs an ID so the Widget can work its magic on them
  106. $('dd.fandom ul.autocomplete').attr('id', 'sortable-fan');
  107. $('dd.character ul.autocomplete').attr('id', 'sortable-char');
  108. $('dd.relationship ul.autocomplete').attr('id', 'sortable-rel');
  109. $('dd.freeform ul.autocomplete').attr('id', 'sortable-ff');
  110.  
  111. // 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.
  112. // 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
  113. // Map { "work_fandom" -> [ 0: "fandom a", 1: "fandom b"... ],
  114. // "work_character" -> [0: "char A", 1: "char B"...], ... }
  115. const ogTags = new Map();
  116. ["work_freeform", "work_character", "work_relationship", "work_fandom"].forEach((type) => {
  117. // 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
  118. // value property gets updated on the fly by AO3 JS with unsaved tags on page refresh -> need to use value attribute instead
  119. var ogTagsGroup = document.getElementById(type).getAttribute('value').split(",").map( (tag) => tag.trim() );
  120. ogTags.set(type, ogTagsGroup);
  121.  
  122. // check all tags on page if they were really saved before. if so, give them a fixed class, otherwise a draggable class
  123. $(`#${type}`).prev().children("li.added.tag").each((i, e) => {
  124. if ( ogTagsGroup.includes(getTagText(e)) )
  125. $(e).addClass('fixed').attr('title', "Sorry, this tag can't be resorted");
  126. else {
  127. $(e).addClass('reorder');
  128. if (mobile) $(e).prepend(`<span class="mobile">${icon_handle}</span>`);
  129. }
  130. });
  131. });
  132.  
  133. // on pageload: make the tag lists sortable
  134. $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').sortable({
  135. items: '> li.added.tag.reorder', // only the draggable LIs
  136. tolerance: 'pointer', // makes the movement behavior more predictable
  137. revert: true, // animates reversal when dropped where it can't be sorted
  138. opacity: 0.5, // transparency for the handle that's being dragged around
  139. cursor: "grabbing", // switches cursor while dragging a tag for A+ cursor responsiveness
  140. });
  141. if (mobile) $( '#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff' ).sortable( "option", "handle", ".mobile" );
  142.  
  143. // on user adding items: use .refresh() to make those draggable
  144. const observer = new MutationObserver(function(mutList, obs) {
  145. // skip when triggered by the reordering (remove & add)
  146. // aka if any of the mutations inside it are for a placeholder
  147. var anyPlaceholder = mutList.find( (m) => (
  148. Array.from(m.addedNodes).find((n) => n.matches(".ui-sortable-placeholder")) ||
  149. Array.from(m.removedNodes).find((n) => n.matches(".ui-sortable-placeholder"))
  150. ));
  151. if (anyPlaceholder !== undefined) return;
  152.  
  153. for (const mut of mutList) {
  154. for (const node of mut.addedNodes) { // should only ever be one at a time, but better safe than sorry
  155. obs.disconnect(); // gotta stop watching or our own DOM changes turn this into an infinite loop
  156. if (node.matches("li.added.tag:not(.ui-sortable-placeholder)")) { // making sure we haven't accidentially seen a different change
  157. checkAddedTag(node); // checks if the added tag will actually be sortable by AO3
  158. if (mobile && node.matches("li.added.tag.reorder"))
  159. $(node).prepend(`<span class="mobile">${icon_handle}</span>`);
  160. $(node).parent().sortable("refresh");
  161. }
  162. startObserving(); // restart observing after our DOM changes are done
  163. }
  164. }
  165. });
  166. function startObserving() {
  167. $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').each((i, elem) => {
  168. observer.observe(elem, { attributes: false, childList: true, subtree: false });
  169. });
  170. }
  171. startObserving();
  172.  
  173. function checkAddedTag(n) {
  174. const nTagText = getTagText(n); // the pure added tag name
  175. const cTags = Array.from($(n).siblings("li.added.tag")).map((t) => getTagText(t)); // the current list of tags
  176. const ogTagsGroup = ogTags.get($(n).parent().next().attr("id")); // get the og Tags only for the group where the tag was added
  177.  
  178. // CHECK 1: is the added tag a duplicate? AO3 seems to not realize that for anything beyond the first tag in each type
  179. // 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
  180. if (cTags.slice(0, -1).includes(nTagText))
  181. $(n).remove();
  182.  
  183. // 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
  184. else if (ogTagsGroup.includes(nTagText)) {
  185. // step 1: what was its original index?
  186. const ogTagPos = ogTagsGroup.indexOf(nTagText);
  187.  
  188. // step 2: find a predecessor that's still there
  189. var predecessorPos = -1;
  190. for (let i = ogTagPos-1; i >= 0; i--) { // walk backwards through the preceeding og tags
  191. predecessorPos = cTags.indexOf(ogTagsGroup[i]); // check if this og tag is still in the list (if not: -1)
  192. if (predecessorPos > -1) break; // stop if we found one
  193. }
  194.  
  195. // step 3a: if no og predecessor was found anymore, move the added tag to the beginning of the current tags
  196. if (predecessorPos === -1) $(n).prependTo($(n).parent());
  197. // step 3b: if an og predecessor was found, move the added tag behind it
  198. else $(n).insertAfter($(n).siblings().eq(predecessorPos));
  199.  
  200. // step 4: make it fixed again
  201. $(n).addClass('fixed').attr('title', "Sorry, this tag can't be resorted");
  202. }
  203.  
  204. // add a class for easier CSS styling in when we're sorting by handle (mobile)
  205. else $(n).addClass('reorder');
  206. }
  207.  
  208. // on form submit: put everything in the order it's now supposed to be
  209. $(document).on("submit", function() {
  210. $('#sortable-fan, #sortable-char, #sortable-rel, #sortable-ff').each((i, elem) => {
  211. var tags = new Array();
  212. $(elem).find("li.added.tag").each( (ix, tag) => tags.push(getTagText(tag)) );
  213. $(elem).next().val(tags.join(","));
  214. });
  215. });
  216.  
  217. // add copy & delete buttons under each group
  218. $("dt.fandom, dt.character, dt.relationship, dt.freeform").each((i, me) => {
  219. $(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>`)
  220. .append(`<button type="button" class="reorder-delete" title="Removes all ${me.classList[0]} tags at once">${icon_trash}</button>`);
  221. });
  222.  
  223. // copy tags as a comma-separated list
  224. $("#work-form").on("click", "button.reorder-copy", function(e) {
  225. var str = new Array();
  226. $(this).parent().next().find("li.added.tag").each( (ix, tag) => str.push(getTagText(tag)) );
  227. str = str.join(",");
  228.  
  229. copy2Clipboard(e, "txt", str);
  230. });
  231.  
  232. // delete all tags
  233. $("#work-form").on("click", "button.reorder-delete", function (e) {
  234. $(this).parent().next().find("li.added.tag").remove();
  235. });
  236.  
  237.  
  238. // helper function since mobile support makes it more complicated
  239. function getTagText(n) {
  240. // assuming that n is a LI
  241. if (mobile && n.matches(".reorder")) return n.childNodes[1].textContent.trim();
  242. else return n.firstChild.textContent.trim();
  243. }
  244.  
  245. })(jQuery);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址