MediaWiki:Common.js: Difference between revisions
No edit summary |
No edit summary |
||
| 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. */ | ||
/* | |||
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); | |||
} | |||
})(); | |||
/** | /** | ||
* Name: WhatLinksHere | * Name: WhatLinksHere | ||