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. */ | ||
/* 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); | |||
} | |||
})(); |
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);
}
})();