×
Create a new article
Write your page title here:
We currently have 22 articles on Topicalballs Wiki. Type your article name above or click on one of the titles below and start writing!



Topicalballs Wiki

MediaWiki:Common.js: Difference between revisions

LBMapper12 (talk | contribs)
Created page with "Any JavaScript here will be loaded for all users on every page load.: CREDIT TO POLANDBALLRUWIKI FOR THE CSS: body ::-webkit-scrollbar-thumb { border-radius: 8px; border: 2.5px solid transparent; background-clip: content-box; } body ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar { width: 15px; } ::-webkit-scrollbar-track { background: var(--theme-page-color); } ::-webkit-scrollbar-thumb..."
 
LBMapper12 (talk | contribs)
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Any JavaScript here will be loaded for all users on every page load. */
/* Any JavaScript here will be loaded for all users on every page load. */
/* CREDIT TO POLANDBALLRUWIKI FOR THE CSS */
body ::-webkit-scrollbar-thumb {                   
    border-radius: 8px;
    border: 2.5px solid transparent;
    background-clip: content-box;
}


body ::-webkit-scrollbar-track {
/* DOES ANYBODY KNOW HOW TO IMPORT JS SCRIPTS LIke IN FANDOM RAHHHHHHH
    background: transparent;
}


::-webkit-scrollbar {
    SpoilerTags.js
     width: 15px;
     Author: Macklin
}


::-webkit-scrollbar-track {
     Discord-like spoilers that can be toggled on click
     background: var(--theme-page-color);
}


::-webkit-scrollbar-thumb {
*/
     background: var(--theme-text-mix-color);
(function()
}
{
     if (window.dev && window.dev.spoilerTags && window.dev.spoilerTags.loaded)
    {
        console.error("SpoilerTags : Tried to execute more than once");
        return;
    }


::-webkit-scrollbar-corner {
    var SPOILER_CLASSES = [ "spoiler", "spoiler-block", "spoiler-image", "spoiler-blur" ];
     background: none;
    var SPOILER_SELECTOR = "." + SPOILER_CLASSES.join(", .");
}
    var SETTINGS_KEY = "spoilertags";
    var SETTINGS_OPTION = "userjs-" + SETTINGS_KEY;
 
    var defaultConfig = Object.freeze(
    {
        disable: false,
        spoilAll: false,
        spoilAllButton: true,
        toolbarButton: true,
        unspoil: true,
        hover: true,
        selection: false,
        tooltip: true
    });
 
    var stringMappings =
    [
        {
            configName: "tooltipText",
            cssName: "--spoiler-tooltip-text",
            i18nName: "tooltip-text"
        },
        {
            configName: "imageButtonText",
            cssName: "--spoiler-image-button-text",
            i18nName: "image-button-text"
        }
    ];
 
    var toggleMapping =
    {
        enableToggle:
        {
            mask: Math.pow(2, 0),
            property: "disable",
            negate: true
        },
        alwaysSpoilToggle:
        {
            mask: Math.pow(2, 1),
            property: "spoilAll"
        },
        hideSpoilAllButtonToggle:
        {
            mask: Math.pow(2, 2),
            property: "spoilAllButton",
            negate: true
        },
        hideToolbarToggle:
        {
            mask: Math.pow(2, 3),
            property: "toolbarButton",
            negate: true
        },
        disableHoverToggle:
        {
            mask: Math.pow(2, 4),
            property: "hover",
            negate: true
        },
        allowSelectionToggle:
        {
            mask: Math.pow(2, 5),
            property: "selection"
        }
     };
 
    var st;
    var util =
    {
        // HTML
 
        setAttributes: function(elem, attrs)
        {
            for (var key in attrs)
            {
                // Pass a value of null to remove the attribute
                if (attrs[key] == null)
                    elem.removeAttribute(key);
                else
                    elem.setAttribute(key, attrs[key]);
            }
           
            return elem;
        },
 
        // CSS
 
        // Find all CSS rules that match a specific selector, optionally in a specific stylesheet
        // Returns an array of the matching rules, or an empty array if none were found
        // When firstOnly is true, the function will return the first matching rule, or null if none were found
        findCSSRules: function(selectorString, styleSheet, firstOnly)
        {
            // helper function searches through the document stylesheets looking for @selectorString
            // will also recurse through sub-rules (such as rules inside media queries)
            function recurse(node, selectorString, rules)
            {
                rules = rules || [];
 
// This try-catch is used to avoid throwing errors when trying to
// access rules whose access is restricted by the current CORS
// policy, so that we can continue to iterate.
                try
                {
                    if (node.cssRules)
                    {
                        for (var i = 0; i < node.cssRules.length; i++)
                        {
                            var rule = node.cssRules[i];
   
                            if (rule.selectorText == selectorString)
                            {
                                rules.push(rule);
                                if (firstOnly) return [ rule ];
                            }
   
                            // If this rule has sub-rules (via media queries, recurse them too)
                            if (rule.cssRules && rule.cssRules.length > 0)
                                recurse(rule, selectorString, rules);
   
                            // If this rule is an import, traverse its stylesheet
                            if (rule instanceof CSSImportRule && rule.styleSheet != null)
                                recurse(rule.styleSheet, selectorString, rules)
                        }
                    }
                }
                catch (error){}
                   
                return rules;
            }
   
   
            // Find from a specific sheet
            if (styleSheet)
            {
                return recurse(styleSheet, selectorString);
            }
   
            // Find from all stylesheets in document
            else
            {
                var rules = [];
               
                for (var i = 0; i < document.styleSheets.length; i++)
                {
                    var sheet = document.styleSheets[i];
                    recurse(sheet, selectorString, rules);
                }
 
                if (firstOnly)
                    return (rules.length > 0) ? rules[0] : null;
                else
                    return rules;
            }
        },
 
        findCSSRulesMatching: function(selectorString, styleSheet, predicate)
        {
            return util.css.findCSSRules(":root").filter(predicate);
        },
 
        // Strings
       
        generateRandomString: function(length)
        {
            var result = "";
            var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
            var charsLength = chars.length;
            var counter = 0;
            while (counter < length)
            {
                result += chars.charAt(Math.floor(Math.random() * charsLength));
                counter += 1;
            }
            return result;
        },
 
        trimChar: function(str, char)
        {
            var start = 0, end = str.length;
   
            while(start < end && str[start] === char)
                ++start;
       
            while(end > start && str[end - 1] === char)
                --end;
       
            return (start > 0 || end < str.length) ? str.substring(start, end) : str;
        },
 
        trimChars: function(str, chars)
        {
            for (var i = 0; i < chars.length; i++)
                str = this.trimChar(str, chars[i]);
 
            return str;
        },
 
        // Scripts/modules
       
        getModuleName: function(name)
        {
            if (!name) return null;
            return mw.loader.getModuleNames().find(function(n){ return n === name || n.startsWith(name + "-"); });
        },
 
        // Calls f when the i18n script is ready and all our messages have loaded
        msg: function(tag, arg)
        {
            return window.dev.spoilerTags.i18n.msg(tag, arg).plain();
        },
 
        // Creates a WDSIcons placeholder, which can later be rendered with wds.render on any ancestor
        wdsTemp: function(name, attrs, tag)
        {
            var icon = document.createElement(tag || "div");
            icon.id = "dev-wds-icons-" + name;
 
            this.setAttributes(icon, attrs);
            return icon;
        },
 
        // Flags/bitmask operations
 
        setFlag: function(flags, f)
        {
            flags |= f;
            return flags;
        },
 
        unsetFlag: function(flags, f)
        {
            flags &= ~f;
            return flags;
        },
 
        isFlagSet: function(flags, f)
        {
            return (flags & f) > 0;
        }
    };
   
    if (document.readyState == "loading")
        document.addEventListener("readystatechange", init);
    else
        init();
 
    function init()
    {
        window.dev = window.dev || {};
 
        st = window.dev.spoilerTags =
        {
            loaded: true,
            events: new EventTarget(),
            doNotPropegate: false,
 
            getNumSpoiled: function()
            {
                return this.spoilers.filter(function(s){ return s.spoiled == true; }).length;
            },
 
            isAllSpoiled: function()
            {
                return this.getNumSpoiled() == this.spoilers.length;
            },
 
            canUnspoilAny: function()
            {
                return this.spoilers.some(function(s){ return s.canUnspoil(); });
            },
           
            showAllSpoilers: function()
            {
                this.toggleAllSpoilers(false);
            },
           
            hideAllSpoilers: function()
            {
                this.toggleAllSpoilers(false);
            },
           
            toggleAllSpoilers: function(value, force)
            {
                for (var i = 0; i < this.spoilers.length; i++)
                    this.spoilers[i].toggle(value, force);
            },
           
            addSpoiler: function(elem)
            {
                var s = new Spoiler(elem);
                this.spoilers.push(s);
                s.init();
            }
        };
 
        st.elements = {};
 
        preprocessMultilineSpoilers();
        preprocessGallerySpoilers();
 
        // Collection of spoiler elements
        st.spoilerElems = document.querySelectorAll(SPOILER_SELECTOR);
 
        // An array of all spoilers
        st.spoilers = [];
 
        // An array of all spoiler groups
        st.groups = [];
       
        // Groups keyed by BOTH the element reference and the data-group id
        st.groupLookup = new Map();
 
        // Spoilers keyed by their element reference
        st.spoilerLookup = new Map();
 
        // This is called when ANY spoiler changes states
        st.events.addEventListener("spoiled", function(e)
        {
            // If we've disabled toggling spoilers
            /*
            if (st.forceState == (e.detail.isSpoiled ? "unspoiled" : "spoiled"))
            {
                e.preventDefault();
                return;
            }
            */
 
            if (st.config.spoilAllButton == true && st.spoilAllButton)
            {
                if (st.isAllSpoiled())
                {
                    st.spoilAllButton.classList.toggle("spoiled", true);
                    st.spoilAllButton.dataset.wdsTooltip = util.msg("unspoil-all-tooltip");
                    st.spoilAllButton.setAttribute('aria-label', util.msg("unspoil-all-tooltip"));
                }
                else
                {
                    st.spoilAllButton.classList.toggle("spoiled", false);
                    st.spoilAllButton.dataset.wdsTooltip = util.msg("spoil-all-tooltip");
                    st.spoilAllButton.setAttribute('aria-label', util.msg("spoil-all-tooltip"));
                }
            }
        });
       
        var imports =
        [
            "u:dev:MediaWiki:WDSIcons/code.js",
            "u:dev:MediaWiki:I18n-js/code.js",
            "oojs-ui-core",
            "oojs-ui-windows",
            "mediawiki.widgets",
            "mediawiki.user"
        ];
 
        Promise.resolve()
        .then(function(){ return importArticles({ articles: imports }); })
        .then(function(){ return Promise.all([ loadMessages(), loadStyles() ] ); })
        .then(function()
        {
            fetchConfig();
            applyConfig();
            applyConfigStrings();
            applySpoilerTags();
            createSideToolsButton();
            createSpoilerSettings();
            createToolbarShortcut();
 
            mw.hook("dev.spoilerTags").fire(window.dev.spoilerTags);
        });
    }
 
    // Wait for i18n hook and loaded messages
    function loadMessages()
    {
        var CACHE_VERSION = 1;
       
        return new Promise(function(resolve, reject)
        {
            mw.hook("dev.i18n").add(function(i18n)
            {
                i18n.loadMessages("SpoilerTags", { cacheVersion: CACHE_VERSION }).then(function(i18n)
                {
                    window.dev.spoilerTags.i18n = i18n;
                    resolve();
                });
            });
        });
    }
 
    // Doesn't actually load styles, just ensures that they are loaded
    function loadStyles()
    {
        return new Promise(function(resolve, reject)
        {
            var tryLoadStyleDelay = 250;      // <- Time to wait between checking for rules
            var tryLoadStyleTimeout = 10000;  // <- Total time before giving up
            var tryLoadStyleTime =  0;
   
            function fetchLoop()
            {
                var pageStyles = getComputedStyle(document.body);
 
                // Before trying to find the sheet, just look at the computed styles. This saves some processing time
                if (pageStyles.getPropertyValue("--spoiler-tags-loaded") == "1")
                {
                    st.spoilerCSS = util.findCSSRules(":root").find(function(r){ return r.cssText.includes("--spoiler-tags-loaded"); });
                }
 
                // If the style sheet is loaded via importArticles or style injection, the rules that are
                // fetched above may not exist, and we will need to wait for it to finish loading.
                // This is because the application of the config relies on the existance of these styles.
                if (st.spoilerCSS == null)
                {
                    if (tryLoadStyleTime == 0)
                        console.warn("SpoilerTags : SpoilerTags.css styles were not found (could be loading via JS?), waiting until they are imported...");
   
                    else if (tryLoadStyleTime > tryLoadStyleTimeout)
                    {
                        console.error("SpoilerTags : SpoilerTags.css styles were not imported after " + (tryLoadStyleTimeout / 1000) + " seconds! Ensure you have correctly set up the CSS imports.");
                        reject();
                        return;
                    }
   
                    tryLoadStyleTime += tryLoadStyleDelay;
                    setTimeout(fetchLoop, tryLoadStyleDelay);
                }
                else
                {
                    console.log("SpoilerTags : Found SpoilerTags.css!");
                    resolve();
                }
            }
 
            fetchLoop();
        });
    }
 
    function fetchConfig()
    {
        // This is the config from JavaScript (personal or community), with the defaults
        st.siteConfig = Object.assign({}, defaultConfig, window.spoilerTags);
 
        // This is the config set in the setting UI
        // It contains a subset of the options available in the site config
        if (mw.user.isAnon())
        {
            var cookie = mw.cookie.get(SETTINGS_KEY);
           
            // Fetch user config from cookies
            st.userConfig = bitmaskToUserConfig(cookie);
 
            // Write directly back into cookies upon reading to refresh expiry time
            mw.cookie.set(SETTINGS_KEY, cookie);
        }
        else
        {
            // Always delete cookie if this is a logged-in user
            mw.cookie.set(SETTINGS_KEY, null);
 
            // Fetch user config from MediaWiki options
            st.userConfig = bitmaskToUserConfig(mw.user.options.get(SETTINGS_OPTION));
        }
 
        // Combine the user and site config
        st.config = Object.assign({}, st.siteConfig, st.userConfig);
 
        // Validate the config types
        for (var k in defaultConfig)
        {
            if (st.config.hasOwnProperty(k))
            {
                if (typeof defaultConfig[k] != typeof st.config[k])
                    console.error("SpoilerTags : The option '" + k + "' must be of type '" + typeof defaultConfig[k] + "' but was of type '" + typeof st.config[k] + "'");
                else
                    continue;
            }
           
            st.config[k] = defaultConfig[k];
        }
 
        // Overrides from URL parameters
        var urlParams = new URLSearchParams(window.location.search);
        if (urlParams.has("stsafemode"))
        {
            console.log("SpoilerTags : Safe mode (ignoring all configs and using defaults)");
            st.config = defaultConfig;
            st.safeMode = true;
        }
 
        if (urlParams.has("stspoilall"))
            st.config.spoilAll = true;
        if (urlParams.has("stdisable"))
        {
            st.config.disable = urlParams.get("stdisable") == "1" ? true :
                                urlParams.get("stdisable") == "0" ? false : st.config.disable;
        }
 
        return st.config;
    }
 
    function applyConfig(config)
    {
        config = config || st.config;
 
        // If the user has "disabled" set in the config...
        if (config.disable)
        {
            // Remove the spoiler classes from the page
            for (var i = 0; i < st.spoilerElems.length; i++)
                DOMTokenList.prototype.remove.apply(st.spoilerElems[i].classList, SPOILER_CLASSES);
        }
 
        // If the user has "spoilAll" set in the config...
        if (config.spoilAll)
        {
            //st.forceState = "spoiled";
 
            if (st.initialized)
            {
                for (var i = 0; i < st.spoilers.length; i++)
                    st.spoilers[i].spoiled = true;
            }
            else
            {
                for (var i = 0; i < st.spoilerElems.length; i++)
                    st.spoilerElems[i].classList.add("spoiled");
            }
        }
 
        // Disable spoiler tooltips globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-tooltips-disabled", config.tooltip == false);
 
        // Disable spoiler hover globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-hover-disabled", config.hover == false);
 
        // Disable spoiler selection globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-selection-disabled", config.selection == false);
    }
 
    // Saves the (user) config to either cookies (for anon) or MediaWiki options
    // Pass this function null to clear the config
    function saveConfig(config)
    {
        var bitmaskStr = null;
       
        if (config != null)
        {
            bitmaskStr = userConfigToBitmask(config).toString();
        }
       
        if (mw.user.isAnon())
        {
            mw.cookie.set(SETTINGS_KEY, bitmaskStr);
        }
        else
        {
            // Remove cookie
            mw.cookie.set(SETTINGS_KEY, null);
           
            // Save directly to options so we don't have to re-retrieve it for this session
            mw.user.options.set(SETTINGS_OPTION, bitmaskStr);
           
            var params =
            {
                action: "options",
                optionname: SETTINGS_OPTION,
                optionvalue: bitmaskStr,
                format: 'json'
            };
           
            var api = new mw.Api();
            api.postWithToken("csrf", params)
            .fail(function(e){ console.error("SpoilerTags : Failed to POST user option: " + e); } );
        }
    }
 
    function bitmaskToUserConfig(bitmask)
    {
        bitmask = parseInt(bitmask);
        if (isNaN(bitmask)) return null;
       
        var config = {};
       
        for (var k in toggleMapping)
        {
            var m = toggleMapping[k];
            config[m.property] = util.isFlagSet(bitmask, m.mask);
        }
 
        return config;
    }
 
    function userConfigToBitmask(config)
    {
        var bitmask = 0;
       
        for (var k in toggleMapping)
        {
            var m = toggleMapping[k];
            if (config[m.property] == true)
                bitmask = util.setFlag(bitmask, m.mask);
        }
 
        return bitmask;
    }
 
    // Modify the original stylesheet directly with the changes from JS
    function applyConfigStrings()
    {
        for (var i = 0; i < stringMappings.length; i++)
        {
            var m = stringMappings[i];
            m.i18nValue = util.msg(m.i18nName);
            m.cssValue = util.trimChars(st.spoilerCSS.style.getPropertyValue(m.cssName), ['"', "'"]);
            m.jsValue = st.config[m.configName];
            m.hasCustomValue = false;
 
            // Check to see if the CSS value has been overidden by a user who may have
            // modified the default CSS (it will not be set in the default SpoilerTags.css)
            if (m.cssValue != "" && m.cssValue != m.i18nValue)
                m.hasCustomValue = true;
 
            // Use the value from JS if it is valid and custom
            // (if a CSS variable is still set outside SpoilerTags.css, it will be used)
            else if (m.jsValue != null && typeof m.jsValue == "string" && m.jsValue != "" && m.jsValue != m.i18nValue)
            {
                if (m.hasCustomValue)
                    console.error("SpoilerTags : Ignoring config string for " + m.configName + "'" + m.jsValue + "' because a custom string was already set in SpoilerTags.css");
                else
                {
                    st.spoilerCSS.style.setProperty(m.cssName, "\"" + m.jsValue + "\"");
                    m.hasCustomValue = true;
                }
            }
 
            // Use defaults from i18n
            else
                st.spoilerCSS.style.setProperty(m.cssName, "\"" + m.i18nValue + "\"");
        }
    }
 
    function createSideToolsButton()
    {
        // Don't create a button if it's disabled, or there are no spoilers
        if (!st.config.spoilAllButton || st.spoilerElems.length == 0) return;
 
        var spoilAllButton = document.createElement("button");
        var spoilAllButtonAriaLabel =  st.isAllSpoiled() ? util.msg("unspoil-all-tooltip") : util.msg("spoil-all-tooltip");
        spoilAllButton.className = "page-side-tool spoil-all-button";
        spoilAllButton.style.display = "none";
        spoilAllButton.setAttribute('aria-label', spoilAllButtonAriaLabel);
 
        var crossIcon = document.createElement("div");
        crossIcon.className = "spoil-cross-icon";
 
        var eyeIcon = util.wdsTemp("eye-small", { class: "spoil-eye-icon" });
        //var eyeIcon = window.dev.wds.icon("eye-small", { class: "spoil-eye-icon" });
        spoilAllButton.append(eyeIcon, crossIcon);
 
        // Create spoil settings button
        var spoilSettingsButton = document.createElement("button");
        spoilSettingsButton.className = "spoil-settings-button";
        spoilSettingsButton.setAttribute('aria-label', util.msg("settings-title"));
 
        var gearIcon = util.wdsTemp("gear-tiny", { class: "spoil-settings-icon" });
        //var gearIcon = window.dev.wds.icon("gear-tiny", { class: "spoil-settings-icon" });
        spoilSettingsButton.append(gearIcon);
        spoilAllButton.append(spoilSettingsButton);
       
        // Here, modify the button based on whether spoilAll is set in the config
        if (st.config.spoilAll)
        {
            spoilAllButton.classList.add("spoiled");
        }
 
        // Finally, add the button to the side tools
        var pageSideTools = document.querySelector(".page-side-tools");
        pageSideTools.appendChild(spoilAllButton);
 
        // Construct the tooltips using the built-in tooltips script
        var tooltipsName = util.getModuleName("tooltips");
        mw.loader.using(tooltipsName, function(require)
        {
            var tooltips = require(tooltipsName);
            tooltips.applyTooltip_1(spoilAllButton, spoilAllButton.classList.contains("spoiled") ? util.msg("unspoil-all-tooltip") : util.msg("spoil-all-tooltip"), "right");
            tooltips.applyTooltip_1(spoilSettingsButton, util.msg("settings-title"), "right");
        });
 
        // Apply icons on placeholders when WDSIcons is loaded
        mw.hook("dev.wds").add(function(wds)
        {
            wds.render(spoilAllButton);
            spoilAllButton.style.display = "";
        });
 
        // Events
 
        // On clicking spoil all button, do just that
        spoilAllButton.addEventListener("click", function(e)
        {
            if (spoilAllButton.classList.contains("disabled")) return;
           
            var numSpoiled = st.getNumSpoiled();
 
            // If there are any spoilers that are unspoiled, spoil all
            // Otherwise if all are spoiled, unspoil all
            st.toggleAllSpoilers(numSpoiled < st.spoilers.length, true);
 
            // In order to avoid hiding the tooltip on click, dispatch a "mouseenter" event on the next frame
            if (e.srcElement == e.currentTarget)
                requestAnimationFrame(function(){ spoilAllButton.dispatchEvent(new Event("mouseenter")); });
        });
 
        spoilAllButton.addEventListener("focus", function(e)
        {
            requestAnimationFrame(function(){ spoilAllButton.dispatchEvent(new Event("mouseleave")); });
        });
       
        spoilSettingsButton.addEventListener("click", function(e)
        {
            spoilAllButton.blur();
            spoilSettingsButton.blur();
            showSpoilerSettings();
            e.stopPropagation();
        });
       
        spoilSettingsButton.addEventListener("mouseenter", function(e){ spoilAllButton.dispatchEvent(new Event("mouseleave")); });
        spoilSettingsButton.addEventListener("mouseleave", function(e){ spoilAllButton.dispatchEvent(new Event("mouseenter")); });
 
        st.spoilAllButton = spoilAllButton;
        st.spoilSettingsButton = spoilSettingsButton;
    }
 
    function showSpoilerSettings()
    {
        st.settingsDialog.getManager().openWindow(st.settingsDialog);
    }
 
    function createSpoilerSettings()
    {
        // Example: Creating and opening a process dialog window.
        function SpoilerOptionsDialog(config)
        {
            SpoilerOptionsDialog.super.call(this, config);
        }
       
        OO.inheritClass(SpoilerOptionsDialog, OO.ui.ProcessDialog);
       
        SpoilerOptionsDialog.static.name = "spoilerTagsOptions";
        SpoilerOptionsDialog.static.title = util.msg("settings-title");
        SpoilerOptionsDialog.static.actions =
        [
            { action: 'save', label: util.msg("save"), flags: 'primary' },
            { action: 'safe', label: util.msg("cancel"), flags: 'safe' }
        ];
 
        SpoilerOptionsDialog.prototype.loadSettingsIntoFields = function(configType)
        {
            var config = (configType == "user" ? st.userConfig || st.siteConfig : st.siteConfig);
            var usingUserConfig = configType == "user";
 
            // Dim toggle content when using the defaults or JS config
            this.toggleContent.$element[0].style.opacity = configType == "site" ? "0.65" : "";
 
            // Disable the box holding the custom config if not using a custom config
            for (var k in toggleMapping) this[k].setDisabled(!usingUserConfig);
 
            // Change checked state of useCustomToggle + update help
            this.useCustomToggleField.$help[0].textContent = util.msg("settings-use-custom-info-" + (usingUserConfig ? "enabled" : "disabled"));
           
            // Change state of toggles based on the config
            for (var k in toggleMapping)
            {
                var m = toggleMapping[k];
                var c = config || st.siteConfig || defaultConfig;
                var v = c[m.property] == (m.negate == true ? false : true);
                this[k].setValue(v);
            }
        };
       
        SpoilerOptionsDialog.prototype.initialize = function()
        {
            SpoilerOptionsDialog.super.prototype.initialize.apply(this, arguments);
            var content = new OO.ui.PanelLayout({ padded: true, expanded: false });
           
            var useCustomToggle = new OO.ui.CheckboxInputWidget({ selected: st.userConfig != null });
            var useCustomToggleField = new OO.ui.FieldLayout(useCustomToggle,
            {
                align: "inline",
                helpInline: true,
                label: util.msg("settings-use-custom"),
                help: "~"
            });
 
            useCustomToggle.on("change", function(v)
            {
                this.loadSettingsIntoFields(v ? "user" : "site");
               
            }.bind(this));
 
            content.$element.append(useCustomToggleField.$element);
 
            var enableToggle = new OO.ui.ToggleSwitchWidget({ value: true });
            var alwaysSpoilToggle = new OO.ui.ToggleSwitchWidget();
            var hideSpoilAllButtonToggle = new OO.ui.ToggleSwitchWidget();
            var hideToolbarToggle = new OO.ui.ToggleSwitchWidget();
            var disableHoverToggle = new OO.ui.ToggleSwitchWidget();
            var allowSelectionToggle = new OO.ui.ToggleSwitchWidget();
 
            var fieldset = new OO.ui.FieldsetLayout({ label: util.msg("settings") });
            fieldset.addItems(
            [
                new OO.ui.FieldLayout(enableToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-enable-spoilers"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-enable-spoilers-info"))
                }),
                new OO.ui.FieldLayout(alwaysSpoilToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-always-spoil"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-always-spoil-info"))
                }),
                new OO.ui.FieldLayout(hideSpoilAllButtonToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-hide-spoil-all-button"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-hide-spoil-all-button-info"))
                }),
                new OO.ui.FieldLayout(hideToolbarToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-hide-toolbar"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-hide-toolbar-info")),
                }),
                new OO.ui.FieldLayout(disableHoverToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-disable-hover"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-disable-hover-info"))
                }),
                new OO.ui.FieldLayout(allowSelectionToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-allow-selection"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-allow-selection-info"))
                })
            ]);
           
            // Box surrounding the toggles
            var toggleContent = new OO.ui.PanelLayout( { padded: true, expanded: false, framed: true } );
            toggleContent.$element.append(fieldset.$element);
            content.$element.append(toggleContent.$element);
 
            this.content = content;
            this.useCustomToggle = useCustomToggle;
            this.useCustomToggleField = useCustomToggleField;
            this.toggleContent = toggleContent;
            this.enableToggle = enableToggle;
            this.alwaysSpoilToggle = alwaysSpoilToggle;
            this.hideSpoilAllButtonToggle = hideSpoilAllButtonToggle;
            this.hideToolbarToggle = hideToolbarToggle;
            this.disableHoverToggle = disableHoverToggle;
            this.allowSelectionToggle = allowSelectionToggle;
 
            this.loadSettingsIntoFields(st.userConfig != null ? "user" : "site");
           
            this.$foot.remove();
            this.$body.append(content.$element);
        };
       
        // Called when either action is clicked
        SpoilerOptionsDialog.prototype.getActionProcess = function(action)
        {
            var dialog = this;
           
            if (action == "save")
            {
                // "Use custom settings" was checked
                if (this.useCustomToggle.selected)
                {
                    var settings =
                    {
                        disable: !this.enableToggle.value,
                        spoilAll: this.alwaysSpoilToggle.value,
                        spoilAllButton: !this.hideSpoilAllButtonToggle.value,
                        toolbarButton: !this.hideToolbarToggle.value,
                        hover: !this.disableHoverToggle.value,
                        selection: this.allowSelectionToggle.value
                    };
   
                    saveConfig(settings);
                }
                else
                {
                    // Save null config (this deletes the config)
                    saveConfig(null);
                }
   
                // Also apply the settings
                fetchConfig();
                applyConfig();
            }
           
            return new OO.ui.Process(function()
            {
                dialog.close({ action: action });
            });
           
            //return SpoilerOptionsDialog.super.prototype.getActionProcess.call(this, action);
        };
       
        var windowManager = new OO.ui.WindowManager();
        document.body.append(windowManager.$element[0]);
   
        var dialog = new SpoilerOptionsDialog({ classes: [ "spoilerOptionsDialog" ]});
        windowManager.addWindows([ dialog ]);
        st.settingsDialog = dialog;
    }
 
    function createToolbarShortcut()
    {
        if (!st.config.toolbarButton) return;
       
        // Get the "My Tools" menu. This is a standard menu, but may not always appear
        // (it does not appear if the user has no tools moved underneat it in Customize)
        var toolsMenu = document.querySelector("#my-tools-menu");
       
        // If the menu doesn't exist, create it
        if (toolsMenu == null)
        {
            var myTools = document.querySelector(".mytools");
            myTools = document.createElement("li");
            myTools.className = "mytools menu wds-dropdown wds-is-flipped";
            myTools.style.display = "none";
           
            var toggle = document.createElement("a");
            toggle.style.cursor = "pointer";
            toggle.textContent = "My Tools";
           
            var toggleSpan = document.createElement("span");
            toggleSpan.className = "wds-dropdown__toggle";
            toggleSpan.append(util.wdsTemp("dropdown-tiny", { class: "wds-dropdown__toggle-chevron" }), toggle);
 
            toolsMenu = document.createElement("ul");
            toolsMenu.className = "tools-menu wds-list wds-is-linked";
            toolsMenu.id = "my-tools-menu";
 
            var content = document.createElement("div");
            content.className = "wds-dropdown__content";
            content.append(toolsMenu);
 
            myTools.append(toggleSpan, content);
 
            var tools = document.querySelector("#WikiaBar .toolbar .tools");
            if (tools) tools.prepend(myTools);
           
            mw.hook("dev.wds").add(function(wds)
            {
                wds.render(toggleSpan);
                myTools.style.display = "";
            });
        }
 
        var stButton = document.createElement("a");
        stButton.textContent = util.msg("spoilers");
        stButton.title = util.msg("settings-title");
        var stListItem = document.createElement("li");
        stListItem.append(stButton);
        toolsMenu.append(stListItem);
 
        stButton.addEventListener("click", function(e)
        {
            showSpoilerSettings();
        });
    }
   
    function applySpoilerTags()
    {
        // Start by getting all of the "endpoint" spoilers
        st.spoilerElems.forEach(function(elem)
        {
            var s = new Spoiler(elem);
        });
 
        // Only init after all Spoilers have been set up
        st.spoilers.forEach(function(s)
        {
            s.init();
        });
 
        st.initialized = true;
    }
 
    function getInlineAdjacentNodes(element)
    {
        var result = [];
   
        function traverse(node)
        {
            var currentGroup = [];
            for (var i = 0; i < node.childNodes.length; i++)
            {
                var child = node.childNodes[i];
                var isText = child.nodeType == Node.TEXT_NODE && child.textContent.trim() != "";
                var isInline = child.nodeType == Node.ELEMENT_NODE && isInlineElement(child);
               
                if (isText || isInline)
                    currentGroup.push(child);
                else
                {
                    if (currentGroup.length > 0)
                    {
                        result.push(currentGroup);
                        currentGroup = [];
                    }
                }
                if (child.nodeType === Node.ELEMENT_NODE && !isInline)
                {
                    traverse(child);
                }
            }
            if (currentGroup.length > 0)
            {
                result.push(currentGroup);
            }
        }
   
        function isInlineElement(element)
        {
            var computedStyle = window.getComputedStyle(element);
            return computedStyle.display == "inline" || computedStyle.display === "inline-block";
        }
   
        traverse(element);
        return result;
    }
 
    function preprocessMultilineSpoilers()
    {
        var multilineSpoilers = document.querySelectorAll("div.spoiler");
        for (var i = 0; i < multilineSpoilers.length; i++)
        {
            var spoiler = multilineSpoilers[i];
            spoiler.classList.remove("spoiler");
            spoiler.classList.add("spoiler-group");
 
            var adjacentNodes = getInlineAdjacentNodes(spoiler);
 
            for (var t = 0; t < adjacentNodes.length; t++)
            {
                var span = document.createElement("span");
                span.className = "spoiler";
 
                for (var n = 0; n < adjacentNodes[t].length; n++)
                {
                    if (n == 0) adjacentNodes[t][n].before(span);
                    span.append(adjacentNodes[t][n]);
                }
            }
        }
    }
 
    function preprocessGallerySpoilers()
    {
        var gallerySpoilers = document.querySelectorAll(".spoiler-gallery");
        for (var g = 0; g < gallerySpoilers.length; g++)
        {
            var spoiler = gallerySpoilers[g];
            spoiler.classList.remove("spoiler-gallery");
 
            var images = spoiler.querySelectorAll(".gallery-image-wrapper");
 
            for (var i = 0; i < images.length; i++)
                images[i].classList.add("spoiler-image");
        }
    }
 
    // A spoiler represents either a single span.spoiler element
    // or a collection of span.spoiler elements whose contents can
    // be blanked out in order to avoid spoilers
    function Spoiler(elem)
    {
        if (elem == null || elem.parentNode == null) return;
 
        this.element = elem;
        this.element.spoiler = this;
        this.spoiled = this.spoiled || false;
        this.hovered = false;
 
        // Get parent spoiler if this is a nested spoiler
        this.parent = this.element.parentElement.closest(SPOILER_SELECTOR);
 
        // Get all child spoilers, filtering out nested spoilers (only get first descendants)
        this.children = Array.from(this.element.querySelectorAll(SPOILER_SELECTOR));
        this.children = Array.from(this.children.filter(function(c) { return c.parentElement.closest(SPOILER_SELECTOR) == elem; }));
       
        this.tryFetchGroups();
 
        st.spoilers.push(this);
        st.spoilerLookup.set(this.element, this);
   
        return this;
    }
   
    Spoiler.prototype =
    {
        init: function()
        {
            this.initialized = true;
           
            // Convert elements to Spoiler/SpoilerGroup references
            if (this.parent) this.parent = st.spoilerLookup.get(this.parent);
            if (this.children && this.children.length > 0)
            {
                for (var i = 0; i < this.children.length; i++)
                    this.children[i] = st.spoilerLookup.get(this.children[i]);
            }
 
            // Force disable unspoiling for image spoilers
            if (this.element.classList.contains("spoiler-image") && this.element.dataset.unspoil == null)
                this.element.dataset.unspoil = false;
 
            // Add event listeners
            this.element.addEventListener("click", this);
            this.element.addEventListener("keydown", this);
            this.element.addEventListener("mouseenter", this);
            this.element.addEventListener("mouseleave", this);
        },
   
        deinit: function()
        {
            this.initialized = false;
            this.element.removeEventListener("click", this);
            this.element.removeEventListener("keydown", this);
            this.element.removeEventListener("mouseenter", this);
            this.element.removeEventListener("mouseleave", this);
        },
 
        // Bind the spoiled property to the classlist so that changes made to the class
        // directly (by the editor/user) will properly reflect on the state of JS
        get spoiled()
        {
            return this.element != null ? this.element.classList.contains("spoiled") : this._spoiled;
        },
 
        set spoiled(v)
        {
            if (this.element)
            {
                this.element.classList.toggle("spoiled", v);
 
                // Set some accessibility attributes depending on the spoiled state
                util.setAttributes(this.element,
                {
                    "aria-expanded": v.toString(),
                    "role": v ? "presentation" : "button",
                    "tabindex": 0,
                    "aria-label": v ? null : "Spoiler"
                });
            }
            else
                this._spoiled = v;
        },
   
        tryFetchGroups: function()
        {
            this.groups = [];
           
            // Spoiler should be grouped because it has a data-group attribute
            if (this.element.dataset.group != null)
                this.element.dataset.group.split(",").forEach(function(id){ this.tryAddToGroup(id); }.bind(this));
       
            // Spoiler should be grouped because it is parented
            // But don't group if this spoiler is nested!
            var groupElem = this.element.closest(".spoiler-group");
            if (groupElem != null && this.parent == null)
                this.tryAddToGroup(groupElem);
        },
   
        // id, like the constructor, is either a data-group string or a group element
        tryAddToGroup: function(id)
        {
            if (id == null) return;
           
            var group;
           
            // Get existing group
            if (st.groupLookup.has(id))
            {
                group = st.groupLookup.get(id);
 
                // This spoiler is already in this group
                if (this.groups.includes(group))
                    return;
            }
   
            // Create new group
            else
                group = new SpoilerGroup(id);
   
            // Add this spoiler as a child of group
            group.spoilers.push(this);
   
            // Add to this spoiler's groups array
            this.groups.push(group);
        },
   
        hoverOn: function(){ this.hover(true); },
        hoverOff: function(){ this.hover(false); },
           
        hover: function(value)
        {
            if (value != null && typeof value == "boolean")
                this.hovered = value;
            else
                this.hovered = !this.hovered;
           
            this.element.classList.toggle("hovered", this.hovered);
            this.propegate(this.hover, value);
        },
       
        show: function(){ this.toggle(true); },
        hide: function(){ this.toggle(false); },
 
        // Toggle the spoiler. true is spoiled, false is unspoiled
        toggle: function(value, force)
        {
            if (value == null || typeof value != "boolean")
                value = !this.spoiled;
 
            if (value != this.spoiled || force)
            {
                if (!force)
                {
                    // Check whether we can spoil by seeing if the parent is spoiled
                    if (this.parent && this.parent.spoiled == false && value == true)
                        return;
   
                    // Do not allow un-spoiling if the requirements for that are met
                    if (value == false && !this.canUnspoil())
                        return;
   
                    // Don't toggle off if the selected text includes the spoiler
                    var selection = window.getSelection();
                    if (this.spoiled && this.element && selection.type == "Range" && (selection.containsNode(this.element) ||
                        Array.from(this.element.childNodes).some(function(n){ return selection.containsNode(n); })))
                        return;
                }
               
                // This sets the class via the property setter
                this.spoiled = value;
 
                // Dispatch event to indicate that we're about to change the spoiled state
                // Listeners can cancel the event, which causes the spoiler to not be spoiled
                var e = new CustomEvent("spoiled", { cancelable: true, detail: { spoiler: this, isSpoiled: value } });
                if (!st.events.dispatchEvent(e))
                {
                    this.spoiled = !value;
                    return;
                }
            }
           
            // When toggling OFF, unspoil all children
            if (value == false)
            {
                if (this.children)
                {
                    for (var i = 0; i < this.children.length; i++)
                        this.children[i].toggle(false, force);
                }
            }
 
            this.propegate(this.toggle, value, force);
        },
 
        canUnspoil: function()
        {
            if (this.element && this.element.dataset.unspoil != null)
                return this.element.dataset.unspoil != "false";
            //else if (this.parent != null)
            //    return this.parent.canUnspoil(); // <- Uncomment to prevent children from being unspoiled when the parent spoilers don't allow this
            else
                return st.config.unspoil == true;
        },
   
        // We use handleEvent so that listeners can be removed, but to also keep "this" context
        // See: https://kostasbariotis.com/removeeventlistener-and-this
        handleEvent: function(e)
        {
            switch (e.type)
            {
                case "click":
                case "keydown":
                {
                    if (e.type == "keydown")
                    {
                        // Don't respond other keys
                        if (!(e.key == "Enter" || e.key == " " || e.key == "Spacebar"))
                            return;
                       
                        // Prevent default behaviour of space (scroll down)
                        if (e.key != "Enter")
                            e.preventDefault();
                    }
                   
                    // If this click event came from a child spoiler, and has now bubbled up to the parent -> prevent it from toggling this spoiler
                    if (e.target != e.currentTarget && e.target != this.element && this.element.contains(e.target) && e.target.spoiler != null && this.spoiled)
                        return;
                   
                    // If this is a spoiler nested inside another, prevent clicks on nested
                    // from propegating through to parent when the parent is already spoiled
                    // ! Commented out because this is now handled by the above
                    /*
                    if (this.parent && this.parent.spoiled)
                    {
                        // Prevent bubbling up the DOM
                        e.stopPropagation();
                    }
                    */
 
                    if (this.spoiled && e.srcElement.tagName == "IMG" || e.srcElement.tagName == "A")
                    {
                        return;
                    }
                   
                    this.toggle(!this.spoiled);
                    break;
                }
                case "mouseenter": this.hoverOn(e); break;
                case "mouseleave": this.hoverOff(e); break;
            }
 
            // If this spoiler is grouped, forward the event to all spoilers in all groups that this belongs to
           
        },
 
        // Call function on all groups of this spoiler
        propegate: function(f, v1, v2)
        {
            // To prevent groups in this spoiler being called again, get/set a flag that
            // tells subsequent calls to not propegate again
            if (st.doNotPropegate) return;
            st.doNotPropegate = true;
 
            // Saves what groups and spoilers we've already called the function on
            var propegated = [];
           
            for (var g = 0; g < this.groups.length; g++)
            {
                var group = this.groups[g];
                if (propegated.includes(group)) continue;
                propegated.push(group);
               
                for (var s = 0; s < group.spoilers.length; s++)
                {
                    var spoiler = group.spoilers[s];
                   
                    if (propegated.includes(spoiler)) continue;
                    propegated.push(spoiler);
                       
                    f.call(spoiler, v1, v2);
                }
            }
 
            st.doNotPropegate = false;
        }
    };
 
    // A SpoilerGroup is simply a collection of Spoilers, it has no logic of its own
    function SpoilerGroup(elem)
    {
        this.spoilers = [];
       
        if (typeof elem == "string")
        {
            this.id = elem;
            this.element = document.querySelector(".spoiler-group[data-group=\"[" + this.id + "\"]");
        }
        else if (elem instanceof HTMLElement)
        {
            this.id = elem.dataset.group || util.generateRandomString(8);
            this.element = elem;
        }
       
        if (this.id) st.groupLookup.set(this.id, this);
        if (this.element) st.groupLookup.set(this.element, this);
 
        st.groups.push(this);
    }
})();

Latest revision as of 10:27, 12 January 2025

/* Any JavaScript here will be loaded for all users on every page load. */

/* DOES ANYBODY KNOW HOW TO IMPORT JS SCRIPTS LIke IN FANDOM RAHHHHHHH

    SpoilerTags.js
    Author: Macklin

    Discord-like spoilers that can be toggled on click

*/
(function()
{
    if (window.dev && window.dev.spoilerTags && window.dev.spoilerTags.loaded)
    {
        console.error("SpoilerTags : Tried to execute more than once");
        return;
    }

    var SPOILER_CLASSES = [ "spoiler", "spoiler-block", "spoiler-image", "spoiler-blur" ];
    var SPOILER_SELECTOR = "." + SPOILER_CLASSES.join(", .");
    var SETTINGS_KEY = "spoilertags";
    var SETTINGS_OPTION = "userjs-" + SETTINGS_KEY;

    var defaultConfig = Object.freeze(
    {
        disable: false,
        spoilAll: false,
        spoilAllButton: true,
        toolbarButton: true,
        unspoil: true,
        hover: true,
        selection: false,
        tooltip: true
    });

    var stringMappings =
    [
        {
            configName: "tooltipText",
            cssName: "--spoiler-tooltip-text",
            i18nName: "tooltip-text"
        },
        {
            configName: "imageButtonText",
            cssName: "--spoiler-image-button-text",
            i18nName: "image-button-text"
        }
    ];

    var toggleMapping =
    {
        enableToggle:
        {
            mask: Math.pow(2, 0),
            property: "disable",
            negate: true
        },
        alwaysSpoilToggle:
        {
            mask: Math.pow(2, 1),
            property: "spoilAll"
        },
        hideSpoilAllButtonToggle:
        {
            mask: Math.pow(2, 2),
            property: "spoilAllButton",
            negate: true
        },
        hideToolbarToggle:
        {
            mask: Math.pow(2, 3),
            property: "toolbarButton",
            negate: true
        },
        disableHoverToggle:
        {
            mask: Math.pow(2, 4),
            property: "hover",
            negate: true
        },
        allowSelectionToggle:
        {
            mask: Math.pow(2, 5),
            property: "selection"
        }
    };

    var st;
    var util = 
    {
        // HTML

        setAttributes: function(elem, attrs)
        {
            for (var key in attrs)
            {
                // Pass a value of null to remove the attribute
                if (attrs[key] == null)
                    elem.removeAttribute(key);
                else
                    elem.setAttribute(key, attrs[key]);
            }
            
            return elem;
        },

        // CSS

        // Find all CSS rules that match a specific selector, optionally in a specific stylesheet
        // Returns an array of the matching rules, or an empty array if none were found
        // When firstOnly is true, the function will return the first matching rule, or null if none were found
        findCSSRules: function(selectorString, styleSheet, firstOnly)
        {
            // helper function searches through the document stylesheets looking for @selectorString
            // will also recurse through sub-rules (such as rules inside media queries)
            function recurse(node, selectorString, rules)
            {
                rules = rules || [];

				// This try-catch is used to avoid throwing errors when trying to
				// access rules whose access is restricted by the current CORS
				// policy, so that we can continue to iterate.
                try
                {
                    if (node.cssRules)
                    {
                        for (var i = 0; i < node.cssRules.length; i++)
                        {
                            var rule = node.cssRules[i];
    
                            if (rule.selectorText == selectorString)
                            {
                                rules.push(rule);
                                if (firstOnly) return [ rule ];
                            }
    
                            // If this rule has sub-rules (via media queries, recurse them too)
                            if (rule.cssRules && rule.cssRules.length > 0)
                                recurse(rule, selectorString, rules);
    
                            // If this rule is an import, traverse its stylesheet
                            if (rule instanceof CSSImportRule && rule.styleSheet != null)
                                recurse(rule.styleSheet, selectorString, rules)
                        }
                    }
                }
                catch (error){}
                    
                return rules;
            }
    
    
            // Find from a specific sheet
            if (styleSheet)
            {
                return recurse(styleSheet, selectorString);
            }
    
            // Find from all stylesheets in document
            else
            {
                var rules = [];
                
                for (var i = 0; i < document.styleSheets.length; i++)
                {
                    var sheet = document.styleSheets[i];
                    recurse(sheet, selectorString, rules);
                }

                if (firstOnly)
                    return (rules.length > 0) ? rules[0] : null;
                else
                    return rules;
            }
        },

        findCSSRulesMatching: function(selectorString, styleSheet, predicate)
        {
            return util.css.findCSSRules(":root").filter(predicate);
        },

        // Strings
        
        generateRandomString: function(length)
        {
            var result = "";
            var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
            var charsLength = chars.length;
            var counter = 0;
            while (counter < length)
            {
                result += chars.charAt(Math.floor(Math.random() * charsLength));
                counter += 1;
            }
            return result;
        },

        trimChar: function(str, char)
        {
            var start = 0, end = str.length;
    
            while(start < end && str[start] === char)
                ++start;
        
            while(end > start && str[end - 1] === char)
                --end;
        
            return (start > 0 || end < str.length) ? str.substring(start, end) : str;
        },

        trimChars: function(str, chars)
        {
            for (var i = 0; i < chars.length; i++)
                str = this.trimChar(str, chars[i]);

            return str;
        },

        // Scripts/modules
        
        getModuleName: function(name)
        {
            if (!name) return null;
            return mw.loader.getModuleNames().find(function(n){ return n === name || n.startsWith(name + "-"); });
        },

        // Calls f when the i18n script is ready and all our messages have loaded
        msg: function(tag, arg)
        {
            return window.dev.spoilerTags.i18n.msg(tag, arg).plain();
        },

        // Creates a WDSIcons placeholder, which can later be rendered with wds.render on any ancestor
        wdsTemp: function(name, attrs, tag)
        {
            var icon = document.createElement(tag || "div");
            icon.id = "dev-wds-icons-" + name;

            this.setAttributes(icon, attrs);
            return icon;
        },

        // Flags/bitmask operations

        setFlag: function(flags, f)
        {
            flags |= f;
            return flags;
        },

        unsetFlag: function(flags, f)
        {
            flags &= ~f;
            return flags;
        },

        isFlagSet: function(flags, f)
        {
            return (flags & f) > 0;
        }
    };
    
    if (document.readyState == "loading")
        document.addEventListener("readystatechange", init);
    else
        init();

    function init()
    {
        window.dev = window.dev || {};

        st = window.dev.spoilerTags =
        {
            loaded: true,
            events: new EventTarget(),
            doNotPropegate: false,

            getNumSpoiled: function()
            {
                return this.spoilers.filter(function(s){ return s.spoiled == true; }).length;
            },

            isAllSpoiled: function()
            {
                return this.getNumSpoiled() == this.spoilers.length;
            },

            canUnspoilAny: function()
            {
                return this.spoilers.some(function(s){ return s.canUnspoil(); });
            },
            
            showAllSpoilers: function()
            {
                this.toggleAllSpoilers(false);
            },
            
            hideAllSpoilers: function()
            { 
                this.toggleAllSpoilers(false);
            },
            
            toggleAllSpoilers: function(value, force)
            {
                for (var i = 0; i < this.spoilers.length; i++)
                    this.spoilers[i].toggle(value, force);
            },
            
            addSpoiler: function(elem)
            {
                var s = new Spoiler(elem);
                this.spoilers.push(s);
                s.init();
            }
        };

        st.elements = {};

        preprocessMultilineSpoilers();
        preprocessGallerySpoilers();

        // Collection of spoiler elements
        st.spoilerElems = document.querySelectorAll(SPOILER_SELECTOR);

        // An array of all spoilers
        st.spoilers = [];

        // An array of all spoiler groups
        st.groups = [];
        
        // Groups keyed by BOTH the element reference and the data-group id
        st.groupLookup = new Map();

        // Spoilers keyed by their element reference
        st.spoilerLookup = new Map();

        // This is called when ANY spoiler changes states
        st.events.addEventListener("spoiled", function(e)
        {
            // If we've disabled toggling spoilers
            /*
            if (st.forceState == (e.detail.isSpoiled ? "unspoiled" : "spoiled"))
            {
                e.preventDefault();
                return;
            }
            */

            if (st.config.spoilAllButton == true && st.spoilAllButton)
            {
                if (st.isAllSpoiled())
                {
                    st.spoilAllButton.classList.toggle("spoiled", true);
                    st.spoilAllButton.dataset.wdsTooltip = util.msg("unspoil-all-tooltip");
                    st.spoilAllButton.setAttribute('aria-label', util.msg("unspoil-all-tooltip"));
                }
                else
                {
                    st.spoilAllButton.classList.toggle("spoiled", false);
                    st.spoilAllButton.dataset.wdsTooltip = util.msg("spoil-all-tooltip");
                    st.spoilAllButton.setAttribute('aria-label', util.msg("spoil-all-tooltip"));
                }
            }
        });
        
        var imports =
        [
            "u:dev:MediaWiki:WDSIcons/code.js",
            "u:dev:MediaWiki:I18n-js/code.js",
            "oojs-ui-core",
            "oojs-ui-windows",
            "mediawiki.widgets",
            "mediawiki.user"
        ];

        Promise.resolve()
        .then(function(){ return importArticles({ articles: imports }); })
        .then(function(){ return Promise.all([ loadMessages(), loadStyles() ] ); })
        .then(function()
        {
            fetchConfig();
            applyConfig();
            applyConfigStrings();
            applySpoilerTags();
            createSideToolsButton();
            createSpoilerSettings();
            createToolbarShortcut();

            mw.hook("dev.spoilerTags").fire(window.dev.spoilerTags);
        });
    }

    // Wait for i18n hook and loaded messages
    function loadMessages()
    {
        var CACHE_VERSION = 1;
        
        return new Promise(function(resolve, reject)
        {
            mw.hook("dev.i18n").add(function(i18n)
            {
                i18n.loadMessages("SpoilerTags", { cacheVersion: CACHE_VERSION }).then(function(i18n)
                {
                    window.dev.spoilerTags.i18n = i18n;
                    resolve();
                });
            });
        });
    }

    // Doesn't actually load styles, just ensures that they are loaded
    function loadStyles()
    {
        return new Promise(function(resolve, reject)
        {
            var tryLoadStyleDelay = 250;      // <- Time to wait between checking for rules
            var tryLoadStyleTimeout = 10000;  // <- Total time before giving up
            var tryLoadStyleTime =  0;
    
            function fetchLoop()
            {
                var pageStyles = getComputedStyle(document.body);

                // Before trying to find the sheet, just look at the computed styles. This saves some processing time
                if (pageStyles.getPropertyValue("--spoiler-tags-loaded") == "1")
                {
                    st.spoilerCSS = util.findCSSRules(":root").find(function(r){ return r.cssText.includes("--spoiler-tags-loaded"); });
                }

                // If the style sheet is loaded via importArticles or style injection, the rules that are
                // fetched above may not exist, and we will need to wait for it to finish loading.
                // This is because the application of the config relies on the existance of these styles.
                if (st.spoilerCSS == null)
                {
                    if (tryLoadStyleTime == 0)
                        console.warn("SpoilerTags : SpoilerTags.css styles were not found (could be loading via JS?), waiting until they are imported...");
    
                    else if (tryLoadStyleTime > tryLoadStyleTimeout)
                    {
                        console.error("SpoilerTags : SpoilerTags.css styles were not imported after " + (tryLoadStyleTimeout / 1000) + " seconds! Ensure you have correctly set up the CSS imports.");
                        reject();
                        return;
                    }
    
                    tryLoadStyleTime += tryLoadStyleDelay;
                    setTimeout(fetchLoop, tryLoadStyleDelay);
                }
                else
                {
                    console.log("SpoilerTags : Found SpoilerTags.css!");
                    resolve();
                }
            }

            fetchLoop();
        });
    }

    function fetchConfig()
    {
        // This is the config from JavaScript (personal or community), with the defaults
        st.siteConfig = Object.assign({}, defaultConfig, window.spoilerTags);

        // This is the config set in the setting UI
        // It contains a subset of the options available in the site config
        if (mw.user.isAnon())
        {
            var cookie = mw.cookie.get(SETTINGS_KEY);
            
            // Fetch user config from cookies
            st.userConfig = bitmaskToUserConfig(cookie);

            // Write directly back into cookies upon reading to refresh expiry time
            mw.cookie.set(SETTINGS_KEY, cookie);
        }
        else
        {
            // Always delete cookie if this is a logged-in user
            mw.cookie.set(SETTINGS_KEY, null);

            // Fetch user config from MediaWiki options
            st.userConfig = bitmaskToUserConfig(mw.user.options.get(SETTINGS_OPTION));
        }

        // Combine the user and site config
        st.config = Object.assign({}, st.siteConfig, st.userConfig);

        // Validate the config types
        for (var k in defaultConfig)
        {
            if (st.config.hasOwnProperty(k))
            {
                if (typeof defaultConfig[k] != typeof st.config[k])
                    console.error("SpoilerTags : The option '" + k + "' must be of type '" + typeof defaultConfig[k] + "' but was of type '" + typeof st.config[k] + "'");
                else
                    continue;
            }
            
            st.config[k] = defaultConfig[k];
        }

        // Overrides from URL parameters
        var urlParams = new URLSearchParams(window.location.search);
        if (urlParams.has("stsafemode"))
        {
            console.log("SpoilerTags : Safe mode (ignoring all configs and using defaults)");
            st.config = defaultConfig;
            st.safeMode = true;
        }

        if (urlParams.has("stspoilall"))
            st.config.spoilAll = true;
        if (urlParams.has("stdisable"))
        {
            st.config.disable = urlParams.get("stdisable") == "1" ? true :
                                urlParams.get("stdisable") == "0" ? false : st.config.disable;
        }

        return st.config;
    }

    function applyConfig(config)
    {
        config = config || st.config;

        // If the user has "disabled" set in the config...
        if (config.disable)
        {
            // Remove the spoiler classes from the page
            for (var i = 0; i < st.spoilerElems.length; i++)
                DOMTokenList.prototype.remove.apply(st.spoilerElems[i].classList, SPOILER_CLASSES);
        }

        // If the user has "spoilAll" set in the config...
        if (config.spoilAll)
        {
            //st.forceState = "spoiled";

            if (st.initialized)
            {
                for (var i = 0; i < st.spoilers.length; i++)
                    st.spoilers[i].spoiled = true;
            }
            else
            {
                for (var i = 0; i < st.spoilerElems.length; i++)
                    st.spoilerElems[i].classList.add("spoiled");
            }
        }

        // Disable spoiler tooltips globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-tooltips-disabled", config.tooltip == false);

        // Disable spoiler hover globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-hover-disabled", config.hover == false);

        // Disable spoiler selection globally by adding a class to the <body>
        document.body.classList.toggle("spoiler-selection-disabled", config.selection == false);
    }

    // Saves the (user) config to either cookies (for anon) or MediaWiki options
    // Pass this function null to clear the config
    function saveConfig(config)
    {
        var bitmaskStr = null;
        
        if (config != null)
        {
            bitmaskStr = userConfigToBitmask(config).toString();
        }
        
        if (mw.user.isAnon())
        {
            mw.cookie.set(SETTINGS_KEY, bitmaskStr);
        }
        else
        {
            // Remove cookie
            mw.cookie.set(SETTINGS_KEY, null);
            
            // Save directly to options so we don't have to re-retrieve it for this session
            mw.user.options.set(SETTINGS_OPTION, bitmaskStr);
            
            var params =
            {
                action: "options",
                optionname: SETTINGS_OPTION,
                optionvalue: bitmaskStr,
                format: 'json'
            };
            
            var api = new mw.Api();
            api.postWithToken("csrf", params)
            .fail(function(e){ console.error("SpoilerTags : Failed to POST user option: " + e); } );
        }
    }

    function bitmaskToUserConfig(bitmask)
    {
        bitmask = parseInt(bitmask);
        if (isNaN(bitmask)) return null;
        
        var config = {};
        
        for (var k in toggleMapping)
        {
            var m = toggleMapping[k];
            config[m.property] = util.isFlagSet(bitmask, m.mask);
        }

        return config;
    }

    function userConfigToBitmask(config)
    {
        var bitmask = 0;
        
        for (var k in toggleMapping)
        {
            var m = toggleMapping[k];
            if (config[m.property] == true)
                bitmask = util.setFlag(bitmask, m.mask);
        }

        return bitmask;
    }

    // Modify the original stylesheet directly with the changes from JS
    function applyConfigStrings()
    {
        for (var i = 0; i < stringMappings.length; i++)
        {
            var m = stringMappings[i];
            m.i18nValue = util.msg(m.i18nName);
            m.cssValue = util.trimChars(st.spoilerCSS.style.getPropertyValue(m.cssName), ['"', "'"]);
            m.jsValue = st.config[m.configName];
            m.hasCustomValue = false;

            // Check to see if the CSS value has been overidden by a user who may have
            // modified the default CSS (it will not be set in the default SpoilerTags.css)
            if (m.cssValue != "" && m.cssValue != m.i18nValue)
                m.hasCustomValue = true;

            // Use the value from JS if it is valid and custom
            // (if a CSS variable is still set outside SpoilerTags.css, it will be used)
            else if (m.jsValue != null && typeof m.jsValue == "string" && m.jsValue != "" && m.jsValue != m.i18nValue)
            {
                if (m.hasCustomValue)
                    console.error("SpoilerTags : Ignoring config string for " + m.configName + "'" + m.jsValue + "' because a custom string was already set in SpoilerTags.css");
                else
                {
                    st.spoilerCSS.style.setProperty(m.cssName, "\"" + m.jsValue + "\"");
                    m.hasCustomValue = true;
                }
            }

            // Use defaults from i18n
            else
                st.spoilerCSS.style.setProperty(m.cssName, "\"" + m.i18nValue + "\"");
        }
    }

    function createSideToolsButton()
    {
        // Don't create a button if it's disabled, or there are no spoilers
        if (!st.config.spoilAllButton || st.spoilerElems.length == 0) return;

        var spoilAllButton = document.createElement("button");
        var spoilAllButtonAriaLabel =  st.isAllSpoiled() ? util.msg("unspoil-all-tooltip") : util.msg("spoil-all-tooltip");
        spoilAllButton.className = "page-side-tool spoil-all-button";
        spoilAllButton.style.display = "none";
        spoilAllButton.setAttribute('aria-label', spoilAllButtonAriaLabel);

        var crossIcon = document.createElement("div");
        crossIcon.className = "spoil-cross-icon";

        var eyeIcon = util.wdsTemp("eye-small", { class: "spoil-eye-icon" });
        //var eyeIcon = window.dev.wds.icon("eye-small", { class: "spoil-eye-icon" });
        spoilAllButton.append(eyeIcon, crossIcon);

        // Create spoil settings button
        var spoilSettingsButton = document.createElement("button");
        spoilSettingsButton.className = "spoil-settings-button";
        spoilSettingsButton.setAttribute('aria-label', util.msg("settings-title"));

        var gearIcon = util.wdsTemp("gear-tiny", { class: "spoil-settings-icon" });
        //var gearIcon = window.dev.wds.icon("gear-tiny", { class: "spoil-settings-icon" });
        spoilSettingsButton.append(gearIcon);
        spoilAllButton.append(spoilSettingsButton);
        
        // Here, modify the button based on whether spoilAll is set in the config
        if (st.config.spoilAll)
        {
            spoilAllButton.classList.add("spoiled");
        }

        // Finally, add the button to the side tools
        var pageSideTools = document.querySelector(".page-side-tools");
        pageSideTools.appendChild(spoilAllButton);

        // Construct the tooltips using the built-in tooltips script
        var tooltipsName = util.getModuleName("tooltips");
        mw.loader.using(tooltipsName, function(require)
        {
            var tooltips = require(tooltipsName);
            tooltips.applyTooltip_1(spoilAllButton, spoilAllButton.classList.contains("spoiled") ? util.msg("unspoil-all-tooltip") : util.msg("spoil-all-tooltip"), "right");
            tooltips.applyTooltip_1(spoilSettingsButton, util.msg("settings-title"), "right");
        });

        // Apply icons on placeholders when WDSIcons is loaded
        mw.hook("dev.wds").add(function(wds)
        {
            wds.render(spoilAllButton);
            spoilAllButton.style.display = "";
        });

        // Events

        // On clicking spoil all button, do just that
        spoilAllButton.addEventListener("click", function(e)
        {
            if (spoilAllButton.classList.contains("disabled")) return;
            
            var numSpoiled = st.getNumSpoiled();

            // If there are any spoilers that are unspoiled, spoil all
            // Otherwise if all are spoiled, unspoil all
            st.toggleAllSpoilers(numSpoiled < st.spoilers.length, true);

            // In order to avoid hiding the tooltip on click, dispatch a "mouseenter" event on the next frame
            if (e.srcElement == e.currentTarget)
                requestAnimationFrame(function(){ spoilAllButton.dispatchEvent(new Event("mouseenter")); });
        });

        spoilAllButton.addEventListener("focus", function(e)
        {
            requestAnimationFrame(function(){ spoilAllButton.dispatchEvent(new Event("mouseleave")); });
        });
        
        spoilSettingsButton.addEventListener("click", function(e)
        {
            spoilAllButton.blur();
            spoilSettingsButton.blur();
            showSpoilerSettings();
            e.stopPropagation();
        });
        
        spoilSettingsButton.addEventListener("mouseenter", function(e){ spoilAllButton.dispatchEvent(new Event("mouseleave")); });
        spoilSettingsButton.addEventListener("mouseleave", function(e){ spoilAllButton.dispatchEvent(new Event("mouseenter")); });

        st.spoilAllButton = spoilAllButton;
        st.spoilSettingsButton = spoilSettingsButton;
    }

    function showSpoilerSettings()
    {
        st.settingsDialog.getManager().openWindow(st.settingsDialog);
    }

    function createSpoilerSettings()
    {
        // Example: Creating and opening a process dialog window.
        function SpoilerOptionsDialog(config)
        {
            SpoilerOptionsDialog.super.call(this, config);
        }
        
        OO.inheritClass(SpoilerOptionsDialog, OO.ui.ProcessDialog);
        
        SpoilerOptionsDialog.static.name = "spoilerTagsOptions";
        SpoilerOptionsDialog.static.title = util.msg("settings-title");
        SpoilerOptionsDialog.static.actions =
        [
            { action: 'save', label: util.msg("save"), flags: 'primary' },
            { action: 'safe', label: util.msg("cancel"), flags: 'safe' }
        ];

        SpoilerOptionsDialog.prototype.loadSettingsIntoFields = function(configType)
        {
            var config = (configType == "user" ? st.userConfig || st.siteConfig : st.siteConfig);
            var usingUserConfig = configType == "user";

            // Dim toggle content when using the defaults or JS config
            this.toggleContent.$element[0].style.opacity = configType == "site" ? "0.65" : "";

            // Disable the box holding the custom config if not using a custom config
            for (var k in toggleMapping) this[k].setDisabled(!usingUserConfig);

            // Change checked state of useCustomToggle + update help
            this.useCustomToggleField.$help[0].textContent = util.msg("settings-use-custom-info-" + (usingUserConfig ? "enabled" : "disabled"));
            
            // Change state of toggles based on the config
            for (var k in toggleMapping)
            {
                var m = toggleMapping[k];
                var c = config || st.siteConfig || defaultConfig;
                var v = c[m.property] == (m.negate == true ? false : true);
                this[k].setValue(v);
            }
        };
        
        SpoilerOptionsDialog.prototype.initialize = function()
        {
            SpoilerOptionsDialog.super.prototype.initialize.apply(this, arguments);
            var content = new OO.ui.PanelLayout({ padded: true, expanded: false });
            
            var useCustomToggle = new OO.ui.CheckboxInputWidget({ selected: st.userConfig != null });
            var useCustomToggleField = new OO.ui.FieldLayout(useCustomToggle,
            {
                align: "inline",
                helpInline: true,
                label: util.msg("settings-use-custom"),
                help: "~"
            });

            useCustomToggle.on("change", function(v)
            {
                this.loadSettingsIntoFields(v ? "user" : "site");
                
            }.bind(this));

            content.$element.append(useCustomToggleField.$element);

            var enableToggle = new OO.ui.ToggleSwitchWidget({ value: true });
            var alwaysSpoilToggle = new OO.ui.ToggleSwitchWidget();
            var hideSpoilAllButtonToggle = new OO.ui.ToggleSwitchWidget();
            var hideToolbarToggle = new OO.ui.ToggleSwitchWidget();
            var disableHoverToggle = new OO.ui.ToggleSwitchWidget();
            var allowSelectionToggle = new OO.ui.ToggleSwitchWidget();

            var fieldset = new OO.ui.FieldsetLayout({ label: util.msg("settings") });
            fieldset.addItems(
            [
                new OO.ui.FieldLayout(enableToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-enable-spoilers"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-enable-spoilers-info"))
                }),
                new OO.ui.FieldLayout(alwaysSpoilToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-always-spoil"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-always-spoil-info"))
                }),
                new OO.ui.FieldLayout(hideSpoilAllButtonToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-hide-spoil-all-button"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-hide-spoil-all-button-info"))
                }),
                new OO.ui.FieldLayout(hideToolbarToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-hide-toolbar"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-hide-toolbar-info")),
                }),
                new OO.ui.FieldLayout(disableHoverToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-disable-hover"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-disable-hover-info"))
                }),
                new OO.ui.FieldLayout(allowSelectionToggle,
                {
                    align: "left",
                    //helpInline: true,
                    classes: [ "spoiler-fieldLayout "],
                    label: util.msg("settings-allow-selection"),
                    help: new OO.ui.HtmlSnippet(util.msg("settings-allow-selection-info"))
                })
            ]);
            
            // Box surrounding the toggles
            var toggleContent = new OO.ui.PanelLayout( { padded: true, expanded: false, framed: true } );
            toggleContent.$element.append(fieldset.$element);
            content.$element.append(toggleContent.$element);

            this.content = content;
            this.useCustomToggle = useCustomToggle;
            this.useCustomToggleField = useCustomToggleField;
            this.toggleContent = toggleContent;
            this.enableToggle = enableToggle;
            this.alwaysSpoilToggle = alwaysSpoilToggle;
            this.hideSpoilAllButtonToggle = hideSpoilAllButtonToggle;
            this.hideToolbarToggle = hideToolbarToggle;
            this.disableHoverToggle = disableHoverToggle;
            this.allowSelectionToggle = allowSelectionToggle;

            this.loadSettingsIntoFields(st.userConfig != null ? "user" : "site");
            
            this.$foot.remove();
            this.$body.append(content.$element);
        };
        
        // Called when either action is clicked
        SpoilerOptionsDialog.prototype.getActionProcess = function(action)
        {
            var dialog = this;
            
            if (action == "save")
            {
                // "Use custom settings" was checked
                if (this.useCustomToggle.selected)
                {
                    var settings =
                    {
                        disable: !this.enableToggle.value,
                        spoilAll: this.alwaysSpoilToggle.value,
                        spoilAllButton: !this.hideSpoilAllButtonToggle.value,
                        toolbarButton: !this.hideToolbarToggle.value,
                        hover: !this.disableHoverToggle.value,
                        selection: this.allowSelectionToggle.value
                    };
    
                    saveConfig(settings);
                }
                else
                {
                    // Save null config (this deletes the config)
                    saveConfig(null);
                }
    
                // Also apply the settings
                fetchConfig();
                applyConfig();
            }
            
            return new OO.ui.Process(function()
            {
                dialog.close({ action: action });
            });
            
            //return SpoilerOptionsDialog.super.prototype.getActionProcess.call(this, action);
        };
        
        var windowManager = new OO.ui.WindowManager();
        document.body.append(windowManager.$element[0]);
    
        var dialog = new SpoilerOptionsDialog({ classes: [ "spoilerOptionsDialog" ]});
        windowManager.addWindows([ dialog ]);
        st.settingsDialog = dialog;
    }

    function createToolbarShortcut()
    {
        if (!st.config.toolbarButton) return;
        
        // Get the "My Tools" menu. This is a standard menu, but may not always appear
        // (it does not appear if the user has no tools moved underneat it in Customize)
        var toolsMenu = document.querySelector("#my-tools-menu");
        
        // If the menu doesn't exist, create it
        if (toolsMenu == null)
        {
            var myTools = document.querySelector(".mytools");
            myTools = document.createElement("li");
            myTools.className = "mytools menu wds-dropdown wds-is-flipped";
            myTools.style.display = "none";
            
            var toggle = document.createElement("a");
            toggle.style.cursor = "pointer";
            toggle.textContent = "My Tools";
            
            var toggleSpan = document.createElement("span");
            toggleSpan.className = "wds-dropdown__toggle";
            toggleSpan.append(util.wdsTemp("dropdown-tiny", { class: "wds-dropdown__toggle-chevron" }), toggle);

            toolsMenu = document.createElement("ul");
            toolsMenu.className = "tools-menu wds-list wds-is-linked";
            toolsMenu.id = "my-tools-menu";

            var content = document.createElement("div");
            content.className = "wds-dropdown__content";
            content.append(toolsMenu);

            myTools.append(toggleSpan, content);

            var tools = document.querySelector("#WikiaBar .toolbar .tools");
            if (tools) tools.prepend(myTools);
            
            mw.hook("dev.wds").add(function(wds)
            {
                wds.render(toggleSpan);
                myTools.style.display = "";
            });
        }

        var stButton = document.createElement("a");
        stButton.textContent = util.msg("spoilers");
        stButton.title = util.msg("settings-title");
        var stListItem = document.createElement("li");
        stListItem.append(stButton);
        toolsMenu.append(stListItem);

        stButton.addEventListener("click", function(e)
        {
            showSpoilerSettings();
        });
    }
    
    function applySpoilerTags()
    {
        // Start by getting all of the "endpoint" spoilers
        st.spoilerElems.forEach(function(elem)
        {
            var s = new Spoiler(elem);
        });

        // Only init after all Spoilers have been set up
        st.spoilers.forEach(function(s)
        {
            s.init();
        });

        st.initialized = true;
    }

    function getInlineAdjacentNodes(element) 
    {
        var result = [];
    
        function traverse(node) 
        {
            var currentGroup = [];
            for (var i = 0; i < node.childNodes.length; i++) 
            {
                var child = node.childNodes[i];
                var isText = child.nodeType == Node.TEXT_NODE && child.textContent.trim() != "";
                var isInline = child.nodeType == Node.ELEMENT_NODE && isInlineElement(child);
                
                if (isText || isInline) 
                    currentGroup.push(child);
                else 
                {
                    if (currentGroup.length > 0) 
                    {
                        result.push(currentGroup);
                        currentGroup = [];
                    }
                }
                if (child.nodeType === Node.ELEMENT_NODE && !isInline) 
                {
                    traverse(child);
                }
            }
            if (currentGroup.length > 0) 
            {
                result.push(currentGroup);
            }
        }
    
        function isInlineElement(element) 
        {
            var computedStyle = window.getComputedStyle(element);
            return computedStyle.display == "inline" || computedStyle.display === "inline-block";
        }
    
        traverse(element);
        return result;
    }

    function preprocessMultilineSpoilers()
    {
        var multilineSpoilers = document.querySelectorAll("div.spoiler");
        for (var i = 0; i < multilineSpoilers.length; i++)
        {
            var spoiler = multilineSpoilers[i];
            spoiler.classList.remove("spoiler");
            spoiler.classList.add("spoiler-group");

            var adjacentNodes = getInlineAdjacentNodes(spoiler);

            for (var t = 0; t < adjacentNodes.length; t++)
            {
                var span = document.createElement("span");
                span.className = "spoiler";

                for (var n = 0; n < adjacentNodes[t].length; n++)
                {
                    if (n == 0) adjacentNodes[t][n].before(span);
                    span.append(adjacentNodes[t][n]);
                }
            }
        }
    }

    function preprocessGallerySpoilers()
    {
        var gallerySpoilers = document.querySelectorAll(".spoiler-gallery");
        for (var g = 0; g < gallerySpoilers.length; g++)
        {
            var spoiler = gallerySpoilers[g];
            spoiler.classList.remove("spoiler-gallery");

            var images = spoiler.querySelectorAll(".gallery-image-wrapper");

            for (var i = 0; i < images.length; i++)
                images[i].classList.add("spoiler-image");
        }
    }

    // A spoiler represents either a single span.spoiler element
    // or a collection of span.spoiler elements whose contents can
    // be blanked out in order to avoid spoilers
    function Spoiler(elem)
    {
        if (elem == null || elem.parentNode == null) return;

        this.element = elem;
        this.element.spoiler = this;
        this.spoiled = this.spoiled || false;
        this.hovered = false;

        // Get parent spoiler if this is a nested spoiler
        this.parent = this.element.parentElement.closest(SPOILER_SELECTOR);

        // Get all child spoilers, filtering out nested spoilers (only get first descendants)
        this.children = Array.from(this.element.querySelectorAll(SPOILER_SELECTOR));
        this.children = Array.from(this.children.filter(function(c) { return c.parentElement.closest(SPOILER_SELECTOR) == elem; }));
        
        this.tryFetchGroups();

        st.spoilers.push(this);
        st.spoilerLookup.set(this.element, this);
    
        return this;
    }
    
    Spoiler.prototype = 
    {
        init: function()
        {
            this.initialized = true;
            
            // Convert elements to Spoiler/SpoilerGroup references
            if (this.parent) this.parent = st.spoilerLookup.get(this.parent);
            if (this.children && this.children.length > 0)
            {
                for (var i = 0; i < this.children.length; i++)
                    this.children[i] = st.spoilerLookup.get(this.children[i]);
            }

            // Force disable unspoiling for image spoilers
            if (this.element.classList.contains("spoiler-image") && this.element.dataset.unspoil == null)
                this.element.dataset.unspoil = false;

            // Add event listeners
            this.element.addEventListener("click", this);
            this.element.addEventListener("keydown", this);
            this.element.addEventListener("mouseenter", this);
            this.element.addEventListener("mouseleave", this);
        },
    
        deinit: function()
        {
            this.initialized = false;
            this.element.removeEventListener("click", this);
            this.element.removeEventListener("keydown", this);
            this.element.removeEventListener("mouseenter", this);
            this.element.removeEventListener("mouseleave", this);
        },

        // Bind the spoiled property to the classlist so that changes made to the class
        // directly (by the editor/user) will properly reflect on the state of JS
        get spoiled()
        {
            return this.element != null ? this.element.classList.contains("spoiled") : this._spoiled;
        },

        set spoiled(v)
        {
            if (this.element)
            {
                this.element.classList.toggle("spoiled", v);

                // Set some accessibility attributes depending on the spoiled state
                util.setAttributes(this.element,
                {
                    "aria-expanded": v.toString(),
                    "role": v ? "presentation" : "button",
                    "tabindex": 0,
                    "aria-label": v ? null : "Spoiler"
                });
            }
            else
                this._spoiled = v;
        },
    
        tryFetchGroups: function()
        {
            this.groups = [];
            
            // Spoiler should be grouped because it has a data-group attribute
            if (this.element.dataset.group != null)
                this.element.dataset.group.split(",").forEach(function(id){ this.tryAddToGroup(id); }.bind(this));
        
            // Spoiler should be grouped because it is parented
            // But don't group if this spoiler is nested!
            var groupElem = this.element.closest(".spoiler-group");
            if (groupElem != null && this.parent == null)
                this.tryAddToGroup(groupElem);
        },
    
        // id, like the constructor, is either a data-group string or a group element
        tryAddToGroup: function(id)
        {
            if (id == null) return;
            
            var group;
            
            // Get existing group
            if (st.groupLookup.has(id))
            {
                group = st.groupLookup.get(id);

                // This spoiler is already in this group
                if (this.groups.includes(group))
                    return;
            }
    
            // Create new group
            else
                group = new SpoilerGroup(id);
    
            // Add this spoiler as a child of group
            group.spoilers.push(this);
    
            // Add to this spoiler's groups array
            this.groups.push(group);
        },
    
        hoverOn: function(){ this.hover(true); },
        hoverOff: function(){ this.hover(false); },
            
        hover: function(value)
        {
            if (value != null && typeof value == "boolean")
                this.hovered = value;
            else
                this.hovered = !this.hovered;
            
            this.element.classList.toggle("hovered", this.hovered);
            this.propegate(this.hover, value);
        },
        
        show: function(){ this.toggle(true); },
        hide: function(){ this.toggle(false); },

        // Toggle the spoiler. true is spoiled, false is unspoiled
        toggle: function(value, force)
        {
            if (value == null || typeof value != "boolean")
                value = !this.spoiled;

            if (value != this.spoiled || force)
            {
                if (!force)
                {
                    // Check whether we can spoil by seeing if the parent is spoiled
                    if (this.parent && this.parent.spoiled == false && value == true)
                        return;
    
                    // Do not allow un-spoiling if the requirements for that are met
                    if (value == false && !this.canUnspoil())
                        return;
    
                    // Don't toggle off if the selected text includes the spoiler
                    var selection = window.getSelection();
                    if (this.spoiled && this.element && selection.type == "Range" && (selection.containsNode(this.element) ||
                        Array.from(this.element.childNodes).some(function(n){ return selection.containsNode(n); })))
                        return;
                }
                
                // This sets the class via the property setter
                this.spoiled = value;

                // Dispatch event to indicate that we're about to change the spoiled state
                // Listeners can cancel the event, which causes the spoiler to not be spoiled
                var e = new CustomEvent("spoiled", { cancelable: true, detail: { spoiler: this, isSpoiled: value } });
                if (!st.events.dispatchEvent(e))
                {
                    this.spoiled = !value;
                    return;
                }
            }
            
            // When toggling OFF, unspoil all children
            if (value == false)
            {
                if (this.children)
                {
                    for (var i = 0; i < this.children.length; i++)
                        this.children[i].toggle(false, force);
                }
            }

            this.propegate(this.toggle, value, force);
        },

        canUnspoil: function()
        {
            if (this.element && this.element.dataset.unspoil != null)
                return this.element.dataset.unspoil != "false";
            //else if (this.parent != null)
            //    return this.parent.canUnspoil(); // <- Uncomment to prevent children from being unspoiled when the parent spoilers don't allow this
            else
                return st.config.unspoil == true;
        },
    
        // We use handleEvent so that listeners can be removed, but to also keep "this" context
        // See: https://kostasbariotis.com/removeeventlistener-and-this
        handleEvent: function(e)
        {
            switch (e.type)
            {
                case "click":
                case "keydown":
                {
                    if (e.type == "keydown")
                    {
                        // Don't respond other keys
                        if (!(e.key == "Enter" || e.key == " " || e.key == "Spacebar"))
                            return;
                        
                        // Prevent default behaviour of space (scroll down)
                        if (e.key != "Enter")
                            e.preventDefault();
                    }
                    
                    // If this click event came from a child spoiler, and has now bubbled up to the parent -> prevent it from toggling this spoiler
                    if (e.target != e.currentTarget && e.target != this.element && this.element.contains(e.target) && e.target.spoiler != null && this.spoiled)
                        return;
                    
                    // If this is a spoiler nested inside another, prevent clicks on nested
                    // from propegating through to parent when the parent is already spoiled
                    // ! Commented out because this is now handled by the above
                    /*
                    if (this.parent && this.parent.spoiled)
                    {
                        // Prevent bubbling up the DOM
                        e.stopPropagation();
                    }
                    */

                    if (this.spoiled && e.srcElement.tagName == "IMG" || e.srcElement.tagName == "A")
                    {
                        return;
                    }
                    
                    this.toggle(!this.spoiled);
                    break;
                }
                case "mouseenter": this.hoverOn(e); break;
                case "mouseleave": this.hoverOff(e); break;
            }

            // If this spoiler is grouped, forward the event to all spoilers in all groups that this belongs to
            
        },

        // Call function on all groups of this spoiler
        propegate: function(f, v1, v2)
        {
            // To prevent groups in this spoiler being called again, get/set a flag that
            // tells subsequent calls to not propegate again
            if (st.doNotPropegate) return;
            st.doNotPropegate = true;

            // Saves what groups and spoilers we've already called the function on
            var propegated = [];
            
            for (var g = 0; g < this.groups.length; g++)
            {
                var group = this.groups[g];
                if (propegated.includes(group)) continue;
                propegated.push(group);
                
                for (var s = 0; s < group.spoilers.length; s++)
                {
                    var spoiler = group.spoilers[s];
                    
                    if (propegated.includes(spoiler)) continue;
                    propegated.push(spoiler);
                        
                    f.call(spoiler, v1, v2);
                }
            }

            st.doNotPropegate = false;
        }
    };

    // A SpoilerGroup is simply a collection of Spoilers, it has no logic of its own
    function SpoilerGroup(elem)
    {
        this.spoilers = [];
        
        if (typeof elem == "string")
        {
            this.id = elem;
            this.element = document.querySelector(".spoiler-group[data-group=\"[" + this.id + "\"]");
        }
        else if (elem instanceof HTMLElement)
        {
            this.id = elem.dataset.group || util.generateRandomString(8);
            this.element = elem;
        }
        
        if (this.id) st.groupLookup.set(this.id, this);
        if (this.element) st.groupLookup.set(this.element, this);

        st.groups.push(this);
    }
})();