NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @namespace https://openuserjs.org/users/rhitakorrr // @name Habitica Tags: Level Up // @description Make tags always visible on the tasks page, hide challenge tags, activate groups of tags with one click, and make certain tags mutually exclusive with one another. // @author rhitakorrr // @copyright 2019, Citrusella (CSS) // @copyright 2019, Alys (JS) (https://openuserjs.org/users/Alys) // @copyright 2019, rhitakorrr (JS - extended features) (https://openuserjs.org/users/rhitakorrr) // @licence MIT // @version 1.1.0 // @include https://habitica.com/* // @grant GM_addStyle // @grant GM_getResourceText // @resource config https://gist.githubusercontent.com/robwhitaker/b56c3c24e70b2d2328cb12ec2baa056e/raw // ==/UserScript== // ==OpenUserJS== // @author rhitakorrr // ==/OpenUserJS== GM_addStyle(` .tasks-navigation { height: 210px !important; } .filter-panel { max-height: 170px !important; min-height: 85px !important; max-width: 99% !important; width: 99% !important; left: 0px !important; z-index: 0 !important; overflow-y: scroll !important; display: flex !important; flex-direction: column !important; } .tasks-navigation .tags-list .custom-control { padding-left: 0rem !important; margin-bottom: 0rem !important; margin-left: -.5rem !important; } .tasks-navigation .tags-list .custom-control-input { left: .5rem !important; } .tasks-navigation .tags-list .custom-control .custom-control-label::before,.tasks-navigation .tags-list .custom-control-input:checked ~ .custom-control-label::before,.tasks-navigation .tags-list .custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { visibility: hidden !important; } .tasks-navigation .tags-list .custom-control-label::after,.tasks-navigation .tags-list .custom-control-label::before { left: 0rem !important; } .tasks-navigation .tags-list .custom-checkbox .custom-control-input:checked ~ .custom-control-label { background-color: #96c676; border: 1px solid #F0F6EB; color: #fffffa; } .tasks-navigation .tags-list .custom-control-label { background-color: #F0F6EB; color: #3F6423; border: 1px solid #C4DEB2; border-radius: 12px; padding-left: 8px; padding-right: 8px; } .tasks-navigation .tags-list .col-6 { width: auto !important; flex: none !important; padding: 2px !important; } .tasks-navigation .col-md-4 { width: 100% !important; max-width: 100% !important; flex: 0 0 100% !important; } .offset-md-4 { margin-left: 0.5% !important; } .modal { z-index: 999999999 !important; } .filter-panel-footer { order: 1 !important; padding-top: 12px !important; padding-bottom: 4px !important; } .tags-category { order: 2 !important; padding-top: 6px !important; padding-bottom: 4px !important; } `); // parse the user's configuration try { // strip the "comment" from the default config; JSON doesn't support comments // so the parser will choke otherwise. var rawConfig = GM_getResourceText("config").replace(/\/\*(.|\n)*\*\//, "").trim() var config = JSON.parse(rawConfig); } catch(e) { // if parsing fails, let the user know their config is broken alert("Userscript: Habitica Tags Always Visible\n\n" + "Error: Unable to parse your configuration. Is there an error in your JSON?"); } // return a list of tag labels, possible filtering on a predicate var getTagLabels = (pred) => [].filter.call( document.querySelectorAll(".tags-list.container label"), pred || (() => true) ); // a specialized version of getTagLabels which retrieves a specific label by name var getTagLabelByName = name => getTagLabels(label => label.textContent.trim() === name)[0]; // toggle a tag checkbox on or off; if toggleTo is provided, set it to the specified state // regardless of its current state. var toggleTag = function (tagName, toggleTo) { var tagLabels = getTagLabels(); for (var j = 0; j < tagLabels.length; j++) { //if the tag is the one we're looking for and it needs to be toggled, click the label if (tagLabels[j].textContent.trim() === tagName && tagLabels[j].control.checked !== toggleTo) { tagLabels[j].click(); } } }; // adds a listener to a child tag which will cause selecting or unselecting it to // likewise toggle its parent tag var addListenerToChildTag = function (childTag, parentTag) { var listenerKey = parentTag + ">" + childTag; if (!parentChildListeners[listenerKey]) { parentChildListeners[listenerKey] = true; var checkbox = getTagLabelByName(childTag).control; checkbox.addEventListener("change", function () { if (checkbox.checked) { parentRefs[parentTag] = (parentRefs[parentTag] || 0) + 1; } else { parentRefs[parentTag] -= 1; } toggleTag(parentTag, parentRefs[parentTag] > 0) }); } } // add a listener to an exclusive tag which will toggle off all other tags in its // exclusive group when it is selected var addListenerToExclusiveTag = function (tagKey, excludeGroup, groupId) { var listenerKey = groupId + "-" + tagKey; if (!exclusiveListeners[listenerKey]) { exclusiveListeners[listenerKey] = true; var checkbox = getTagLabelByName(tagKey).control; checkbox.addEventListener("change", function () { excludeGroup.forEach(tag => (tag !== tagKey) && toggleTag(tag, false)); }); } } //set up some variables for state tracking var initialOpening = false; // have we selected our initial tags yet? var filterPanelScrollTop = 0; // keep track of tag filter panel scroll position so it doesn't // awkwardly reset to the top whenever the user mouse-outs var parentRefs = {}; // keep track of how many child tags are referencing each parent so we // don't unselect a parent unless ALL of its child tags are toggled off var parentChildListeners = {}; // keep track of listeners we've added to each parent-child combo // so we don't accidentally add multiple listeners for the same event var exclusiveListeners = {}; // same as parentChildListeners, but for exclusives var callback = function (mutationsList, observer) { var tagsButton = document.querySelector("html body div div#app div.container-fluid div div.row.user-tasks-page div.col-12 div.row.tasks-navigation div.col-12.col-md-4.offset-md-4 div.d-flex button.btn.btn-secondary.dropdown-toggle"); // if the we don't have the tags button, pretend it's a new page load and reset stuff if (!tagsButton) { initialOpening = false; filterPanelScrollTop = 0; parentRefs = {}; } var filterPanel = document.querySelector(".filter-panel"); if (tagsButton && !filterPanel) { // we have the tagsButton, but don't have our filter-panel // if the filter-panel doesn't exist, we've lost all our listeners; reset our listener tracking parentChildListeners = {}; exclusiveListeners = {}; tagsButton.click(); // this clicks on the "Tags" button to show the panel that contains the list of tags } else if (tagsButton && filterPanel) { // our tags are visible // hide challenge tags if (config.hideChallengeTags) { filterPanel.getElementsByClassName("tags-category")[0].style = "display: none !important;" } // restore the filter panel's scroll position and save any scroll changes filterPanel.scrollTop = filterPanelScrollTop; filterPanel.addEventListener("scroll", function () { filterPanelScrollTop = filterPanel.scrollTop; }); // setup tag groups for (var parentTag in config.tagsWithChildren) { var childTags = config.tagsWithChildren[parentTag]; for (var i = 0; i < childTags.length; i++) { addListenerToChildTag(childTags[i], parentTag); } } // setup exclude groups config.exclusive.forEach(function (group, groupId) { for (var i = 0; i < group.length; i++) { addListenerToExclusiveTag(group[i], group, groupId); } }); // automatically select initial tags if (!initialOpening) { config.autoSelected.forEach(tag => toggleTag(tag, true)); initialOpening = true; } } }; var observer = new MutationObserver(callback); var targetNode = document.querySelector('body'); var observerConfig = { childList: true, subtree: true, }; observer.observe(targetNode, observerConfig);