MediaWiki:Gadget-VisibilityToggles.js

Note: You may have to bypass your browser’s cache to see the changes. In addition, after saving a sitewide CSS file such as MediaWiki:Common.css, it will take 5-10 minutes before the changes take effect, even if you clear your cache.

  • Mozilla / Firefox / Safari: hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (Command-R on a Macintosh);
  • Konqueror and Chrome: click Reload or press F5;
  • Opera: clear the cache in Tools → Preferences;
  • Internet Explorer: hold Ctrl while clicking Refresh, or press Ctrl-F5.

/* eslint-env es5, browser, jquery */
/* eslint semi: "error" */
/* jshint esversion: 5, eqeqeq: true */
/* globals $, mw */
/* requires mw.cookie, mw.storage */
(function VisibilityTogglesIIFE () {
"use strict";

// Toggle object that is constructed so that `toggle.status = !toggle.status`
// automatically calls either `toggle.show()` or `toggle.hide()` as appropriate.
// Creating toggle also automatically calls either the show or the hide function.
function Toggle (showFunction, hideFunction) {
	this.show = showFunction, this.hide = hideFunction;
}

Toggle.prototype = {
	get status () {
		return this._status;
	},
	set status (newStatus) {
		if (typeof newStatus !== "boolean")
			throw new TypeError("Value of 'status' must be a boolean.");
		if (newStatus === this._status)
			return;

		this._status = newStatus;

		if (this._status !== this.toggleCategory.status)
			this.toggleCategory.updateToggle(this._status);

		if (this._status)
			this.show();
		else
			this.hide();
	},
};

/*
 * Handles storing a boolean value associated with a `name` stored in
 * localStorage under `key`.
 *
 * The `get` method returns `true`, `false`, or `undefined` (if the storage
 * hasn't been tampered with).
 * The `set` method only allows setting `true` or `false`.
 */
function BooleanStorage(key, name) {
	if (typeof key !== "string")
		throw new TypeError("Expected string");

	if (!(typeof name === "string" && name !== "")) {
		throw new TypeError("Expected non-empty string");
	}
	this.key = key; // key for localStorage
	this.name = name; // name of toggle category

	function convertOldCookie(cookie) {
		return cookie.split(';')
			.filter(function(e) { return e !== ''; })
			.reduce(function(memo, currentValue) {
				var match = /(.+?)=(\d)/.exec(currentValue); // only to test for temporary =[01] format
				if (match) {
					memo[match[1]] = Boolean(Number(match[2]));
				} else {
					memo[currentValue] = true;
				}
				return memo;
			}, {});
	}
	// Look for cookie in old format.
	var cookie = mw.cookie.get(key);
	if (cookie !== null) {
		this.obj = $.extend(this.obj, convertOldCookie(cookie));
		mw.cookie.set(key, null);  // Remove cookie.
	}
}

BooleanStorage.prototype = {
	get: function () {
		return this.obj[this.name];
	},

	set: function (value) {
		if (typeof value !== "boolean")
			throw new TypeError("Expected boolean");

		var obj = this.obj;
		if (obj[this.name] !== value) {
			obj[this.name] = value;
			this.obj = obj;
		}
	},

	// obj allows getting and setting the object version of the stored value.
	get obj() {
		if (typeof this.rawValue !== "string")
			return {};
		try {
			return JSON.parse(this.rawValue);
		} catch (e) {
			if (e instanceof SyntaxError) {
				return {};
			} else {
				throw e;
			}
		}
	},

	set obj(value) {
		// throws TypeError ("cyclic object value")
		this.rawValue = JSON.stringify(value);
	},

	// rawValue allows simple getting and setting of the stringified object.
	get rawValue () {
		return mw.storage.get(this.key);
	},

	set rawValue (value) {
		return mw.storage.set(this.key, value);
	},
};


// This is a version of the actual CSS identifier syntax (described here:
// https://stackoverflow.com/a/2812097), with only ASCII and that must begin
// with an alphabetic character.
var asciiCssIdentifierRegex = /^[a-zA-Z][a-zA-Z0-9_-]+$/;

function ToggleCategory (name, defaultStatus) {
	this.name = name;
	this.sidebarToggle = this.newSidebarToggle();
	this.storage = new BooleanStorage("Visibility", name);
	this.status = this.getInitialStatus(defaultStatus);
}

// Have toggle category inherit array methods.
ToggleCategory.prototype = [];

ToggleCategory.prototype.addToggle = function (showFunction, hideFunction) {
	var toggle = new Toggle(showFunction, hideFunction);
	toggle.toggleCategory = this;
	this.push(toggle);
	toggle.status = this.status;
	return toggle;
};

// Generate an identifier consisting of a lowercase ASCII letter and a random integer.
function randomAsciiCssIdentifier() {
	var digits = 9;
	var lowCodepoint = "a".codePointAt(0), highCodepoint = "z".codePointAt(0);
	return String.fromCodePoint(
			lowCodepoint + Math.floor(Math.random() * (highCodepoint - lowCodepoint)))
		+ String(Math.floor(Math.random() * Math.pow(10, digits)));
}

function getCssIdentifier(name) {
	name = name.replace(/\s+/g, "-");
	// Generate a valid ASCII CSS identifier.
	if (!asciiCssIdentifierRegex.test(name)) {
		// Remove characters that are invalid in an ASCII CSS identifier.
		name = name.replace(/^[^a-zA-Z]+/, "").replace(/[^a-zA-Z_-]+/g, "");
		if (!asciiCssIdentifierRegex.test(name))
			name = randomAsciiCssIdentifier();
	}
	return name;
}

// Add a new global toggle to the sidebar.
ToggleCategory.prototype.newSidebarToggle = function () {
	var name = getCssIdentifier(this.name);
	var id = "p-visibility-" + name;
	
	var sidebarToggle = $("#" + id);
	if (sidebarToggle.length > 0)
		return sidebarToggle;

	var listEntry = $("<li>");
	sidebarToggle = $("<a>", {
			id: id,
			href: "#visibility-" + this.name,
		})
		.click((function () {
			this.status = !this.status;
			this.storage.set(this.status);
			return false;
		}).bind(this));

	listEntry.append(sidebarToggle).appendTo(this.buttons);

	return sidebarToggle;
};

// Update the status of the sidebar toggle for the category when all of its
// toggles on the page are toggled one way.
ToggleCategory.prototype.updateToggle = function (status) {
	if (this.length > 0 && this.every(function (toggle) { return toggle.status === status; }))
		this.status = status;
};

// getInitialStatus is only called when a category is first created.
ToggleCategory.prototype.getInitialStatus = function (defaultStatus) {
	function isFragmentSet(name) {
		return location.hash.toLowerCase().split("_")[0] === "#" + name.toLowerCase();
	}

	function isHideCatsSet(name) {
		var match = /^.+?\?(?:.*?&)*?hidecats=(.+?)(?:&.*)?$/.exec(location.href);
		if (match !== null) {
			var hidecats = match[1].split(",");
			for (var i = 0; i < hidecats.length; ++i) {
				switch (hidecats[i]) {
					case name: case "all":
						return false;
					case "!" + name: case "none":
						return true;
				}
			}
		}
		return false;
	}

	function isWiktionaryPreferencesCookieSet() {
		return mw.cookie.get("WiktionaryPreferencesShowNav") === "true";
	}
	// TODO check category-specific cookies
	return isFragmentSet(this.name)
		|| isHideCatsSet(this.name)
		|| isWiktionaryPreferencesCookieSet()
		|| (function(storedValue) {
            return storedValue !== undefined ? storedValue : Boolean(defaultStatus);
        }(this.storage.get()));
};

Object.defineProperties(ToggleCategory.prototype, {
	status: {
		get: function () {
			return this._status;
		},
		set: function (status) {
			if (typeof status !== "boolean")
				throw new TypeError("Value of 'status' must be a boolean.");
			if (status === this._status)
				return;

			this._status = status;

			// Change the state of all Toggles in the ToggleCategory.
			for (var i = 0; i < this.length; i++)
				this[i].status = status;

			this.sidebarToggle.html((status ? "Hide " : "Show ") + this.name);
		},
	},

	buttons: {
		get: function () {
			var buttons = $("#p-visibility ul");
			if (buttons.length > 0)
				return buttons;
			buttons = $("<ul>");
			var collapsed = mw.cookie.get("vector-nav-p-visibility") === "false";
			var toolbox = $("<div>", {
					"class": "vector-menu vector-menu-portal portal portlet",
					"id": "p-visibility"
				})
				.append($('<label id="p-visibility-label" aria-label="" class="vector-menu-heading"><span class="vector-menu-heading-label">Visibility</span></label>'))
				.append($("<div>", { class: "pBody body vector-menu-content" }).append(buttons));
			var insert = document.getElementById("p-lang") || document.getElementById("p-feedback");
			if (insert) {
				$(insert).before(toolbox);
			} else {
				var sidebar = document.getElementById("mw-panel") || document.getElementById("column-one");
				$(sidebar).append(toolbox);
			}

			return buttons;
		}
	}
});

function VisibilityToggles () {
	// table containing ToggleCategories
	this.togglesByCategory = {};
}

// Add a new toggle, adds a Show/Hide category button in the toolbar.
// Returns a function that when called, calls showFunction and hideFunction
// alternately and updates the sidebar toggle for the category if necessary.
VisibilityToggles.prototype.register = function (category, showFunction, hideFunction, defaultStatus) {
	if (!(typeof category === "string" && category !== ""))
		return;

	var toggle = this.addToggleCategory(category, defaultStatus)
					.addToggle(showFunction, hideFunction);

	return function () {
		toggle.status = !toggle.status;
	};
};

VisibilityToggles.prototype.addToggleCategory = function (name, defaultStatus) {
	return (this.togglesByCategory[name] = this.togglesByCategory[name] || new ToggleCategory(name, defaultStatus));
};

window.alternativeVisibilityToggles = new VisibilityToggles();
window.VisibilityToggles = window.alternativeVisibilityToggles;

})();