MediaWiki:Gadget-defaultVisibilityToggles.js

From Linguifex
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* jshint undef: true */
/* globals $, jQuery, mw, window, getComputedStyle */

(function defaultVisibilityTogglesIIFE() {
	"use strict";
	
	if (window.noDefaultVisibilityToggles) return;
	
	/* == NavBars == */
	var NavigationBarHide = "hide ▲";
	var NavigationBarShow = "show ▼";
	
	var nbsp = "\u00a0";
	
	// Check if an element has been activated with a toggle.
	// For convenience, this has the side effect of marking the element as having
	// a toggle, if it is not already marked.
	// Allows the functions to avoid toggleifying elements more than once, which
	// can lead to multiple "show" buttons, for instance.
	// The argument must be an Element, not a jQuery object.
	function checkAndSetToggleified(element) {
		if (element.isToggleified) {
			return true;
		}
		element.isToggleified = true;
	}
	
	function getToggleCategory(element, defaultCategory) {
		if ($(element).find("table").first().is(".translations"))
			return "translations";
		
		var heading = element;
		while ((heading = heading.previousElementSibling)) {
			// tagName is always uppercase:
			// https://developer.mozilla.org/en-US/docs/Web/API/Element/tagName
			var num = heading.tagName.match(/H(\d)/);
			if (num)
				num = Number(num[1]);
			else
				continue;
			if (4 <= num && num <= 6) {
				if (heading.getElementsByTagName("span")[1])
					heading = heading.getElementsByTagName("span")[0];
				var text = jQuery(heading).text()
					.toLowerCase()
					// jQuery's .text() is inconsistent about whitespace:
					.replace(/^\s+|\s+$/g, "").replace(/\s+/g, " ")
					// remove numbers added by the "Auto-number headings" pref:
					.replace(/^[1-9][0-9.]+ ?/, "");
				// Toggle category must be convertible to a valid CSS identifier so
				// that it can be used in an id selector in jQuery in
				// ToggleCategory.prototype.newSidebarToggle
				// in [[MediaWiki:Gadget-VisibilityToggles.js]].
				// Spaces must later be converted to hyphens or underscores.
				// Reference: https://drafts.csswg.org/selectors-4/#id-selectors
				if (/^[a-zA-Z0-9\s_-]+$/.test(text))
					return text;
				else
					break;
			} else if (num)
				break;
		}
		
		return defaultCategory;
	}
	
	function createNavToggle(navFrame) {
		if (checkAndSetToggleified(navFrame)) {
			return;
		}
		
		var navHead, navContent;
		for (var i = 0, children = navFrame.childNodes; i < children.length; ++i) {
			var child = children[i];
			if (child.nodeName === "DIV") {
				var classList = child.classList;
				if (classList.contains("NavHead"))
					navHead = child;
				if (classList.contains("NavContent"))
					navContent = child;
			}
		}
		if (!(navHead && navContent))
			return;
		
		// Step 1, don't react when a subitem is clicked.
		$(navHead).find("a").on("click", function (e) {
			e.stopPropagation();
			e.stopImmediatePropagation();
		});
				
		// Step 2, toggle visibility when bar is clicked.
		// NOTE This function was chosen due to some funny behaviour in Safari.
		var $navToggle = $("<a>").attr("role", "button").attr("tabindex", "0");
		
		$("<span>").addClass("NavToggle").attr("data-nosnippet", "")
			.append($navToggle)
			.prependTo(navHead);
		
		navHead.style.cursor = "pointer";
		var toggleCategory = $(navFrame).data("toggle-category")
			|| getToggleCategory(navFrame, "other boxes");
		navHead.onclick = window.VisibilityToggles.register(toggleCategory,
			function show() {
				$navToggle.text(NavigationBarHide);
				if (navContent)
					navContent.style.display = "block";
			},
			function hide() {
				$navToggle.text(NavigationBarShow);
				if (navContent)
					navContent.style.display = "none";
			});
	}
	
	function createNavToggleForInflectionTable(it) {
		if (checkAndSetToggleified(it)) {
			return;
		}
		
		// The table caption is the clickable element
		var itCaption = $(it).find("caption").get(0);
		
		// Step 1, don't react when a subitem is clicked.
		$(itCaption).find("a").on("click", function (e) {
			e.stopPropagation();
			e.stopImmediatePropagation();
		});
				
		// Step 2, toggle visibility when bar is clicked.
		// NOTE This function was chosen due to some funny behaviour in Safari.
		var $navToggle = $("<a>").attr("role", "button").attr("tabindex", "0");
		
		$("<span>").addClass("NavToggle").attr("data-nosnippet", "")
			.append($navToggle)
			.prependTo(itCaption);
		
		itCaption.style.cursor = "pointer";
		var toggleCategory = $(it).data("toggle-category")
			|| getToggleCategory(it, "other boxes");
		itCaption.onclick = window.VisibilityToggles.register(toggleCategory,
			function show() {
				$navToggle.text(NavigationBarHide);
				if (it) {
					it.classList.remove("inflection-table-collapsed");
				}
			},
			function hide() {
				$navToggle.text(NavigationBarShow);
				if (it) {
					it.classList.add("inflection-table-collapsed");
				}
			});
			
		// Check to see if we are on a browser that is known to support
		// visibility: collapse, which permits inflection table headings to wrap.
		// WebKit needs to be special-cased, as technically it does support
		// visibility: collapse, but it just implements it as a synonym for
		// visibility: hidden, which is useless. (as of November 2024)
		// Yes, I know User-Agent sniffing is so 2004... but WebKit is the new IE
		if (
			CSS && CSS.supports && CSS.supports("visibility:collapse") &&
			// exclude WebKit/Safari, excepting Blink engines which have a frozen WebKit version number
			(navigator.userAgent.indexOf("AppleWebKit/") === -1 || navigator.userAgent.indexOf("AppleWebKit/537.36") > -1)
		) {
			it.classList.remove("no-vc");
		} else {
			// Strange behaviour occurs when you set the table caption to nowrap
			// The [show/hide] toggle crashes into the caption text
			// This spacer element prevents that
			$("<span>").addClass("no-vc-spacer").appendTo(itCaption);
		}
	}
	
	/* ==Hidden Quotes== */
	function setupHiddenQuotes(li) {
		if (checkAndSetToggleified(li))
			return;
		
		let HQToggleButton, liComp, dl;

		function show() {
			HQToggleButton.text("quotations ▲");
			$(li).children("ul").show();
		}
		function hide() {
			HQToggleButton.text("quotations ▼");
			$(li).children("ul").hide();
		}
		
		for (const liComp of li.childNodes) {
			// Look at each component of the definition.
			if (liComp.tagName === "DL" && !dl)
				dl = liComp;

			// If we find a ul or dl, we have quotes or example sentences, and thus need a button.
			if (liComp.tagName === "UL") {
				$(li).children("ul").addClass("wikt-quote-container");
				HQToggleButton = $("<a>").attr("role", "button").attr("tabindex", "0");
				$(dl || liComp).before($("<span>").addClass("HQToggle").attr("data-nosnippet", "").append(HQToggleButton).css("margin-left", "5px"));
				HQToggleButton.on("click", window.VisibilityToggles.register("quotations", show, hide));
				break;
			}
		}
	}
	
	/* == View Switching == */
	
	function viewSwitching(rootElement) {
		if (checkAndSetToggleified(rootElement)) {
			return;
		}
		
		var $rootElement = $(rootElement);
		var showButtonText = $rootElement.data("vs-showtext") || "more ▼";
		var hideButtonText = $rootElement.data("vs-hidetext") || "less ▲";
		
		var toSkip = $rootElement.find(".vsSwitcher").find("*");
		var elemsToHide = $rootElement.find(".vsHide").not(toSkip);
		var elemsToShow = $rootElement.find(".vsShow").not(toSkip);
		
		// Find the element to place the toggle button in.
		var toggleElement = $rootElement.find(".vsToggleElement").not(toSkip).first();
		
		// The toggleElement becomes clickable in its entirety, but
		// we need to prevent this if a contained link is clicked instead.
		toggleElement.find("a").on("click", function (e) {
			e.stopPropagation();
			e.stopImmediatePropagation();
		});
		
		// Add the toggle button.
		var toggleButton = $("<a>").attr("role", "button").attr("tabindex", "0");
		
		$("<span>").addClass("NavToggle").attr("data-nosnippet", "").append(toggleButton).prependTo(toggleElement);
		
		// Determine the visibility toggle category (for the links in the bar on the left).
		var toggleCategory = $rootElement.data("toggle-category");
		if (!toggleCategory) {
			var classNames = $rootElement.attr("class").split(/\s+/);
			
			for (var i = 0; i < classNames.length; ++i) {
				var className = classNames[i].split("-");
				
				if (className[0] == "vsToggleCategory") {
					toggleCategory = className[1];
				}
			}
		}
		
		if (!toggleCategory)
			toggleCategory = "others";
		
		// Register the visibility toggle.
		toggleElement.css("cursor", "pointer");
		toggleElement.on("click", window.VisibilityToggles.register(toggleCategory,
			function show() {
				toggleButton.text(hideButtonText);
				elemsToShow.hide();
				elemsToHide.show();
			},
			function hide() {
				toggleButton.text(showButtonText);
				elemsToShow.show();
				elemsToHide.hide();
			}));
	}
	
	/* ==List switching== */
	function enableListSwitchGeneric(rootElement) {
		if (checkAndSetToggleified(rootElement)) {
			return;
		}
		
		var $rootElement = $(rootElement);
		
		// Create a toggle button.
		var $toggleElement = $("<div>").addClass("list-switcher-element");
		var $navToggle = $("<span>").addClass("NavToggle").attr("data-nosnippet", "");
		var $toggleButton = $("<a>").attr("role", "button").attr("tabindex", "0");
		
		// Add the toggle button to the DOM tree.
		$navToggle.append($toggleButton).prependTo($toggleElement);
		$toggleElement.insertAfter($rootElement);
		$toggleElement.show();
		
		// Determine the visibility toggle category (for the links in the bar on the
		// left). It will either be the value of the "data-toggle-category"
		// attribute or will be based on the text of the closest preceding
		// fourth-to-sixth-level header.
		var toggleCategory = $rootElement.data("toggle-category")
			|| getToggleCategory($rootElement[0], "other lists");
		
		// Determine the text for the $toggleButton.
		var showButtonText = "show more ▼";
		var hideButtonText = "show less ▲";
		var numItems;
		// special handling for [[Module:collapsible category tree]]
		var $categoryTreeTag = $rootElement.children(".CategoryTreeTag");
		if ($categoryTreeTag) {
			if ($categoryTreeTag.attr("data-pages-left-over") !== "0") {
				// some category members are omitted from the list (MediaWiki limitation)
				// just use basic "show more/less" in this case for now, this is a big change
				//showButtonText = "show first 200 of " + $categoryTreeTag.attr("data-pages-in-cat") + " ▼";
				//hideButtonText = "show fewer ▲";
			} else {
				// all category members are included in the list
				numItems = $categoryTreeTag.attr("data-pages-in-cat");
			}
		} else {
			// standard list-switcher using <li> elements
			numItems = $rootElement.find("li").length;
		}
		if (numItems) {
			showButtonText = "show all " + numItems + " ▼";
			hideButtonText = "show fewer ▲";	
		}
		
		// Register the visibility toggle.
		$toggleElement.on("click", window.VisibilityToggles.register(toggleCategory,
			function show() {
				$toggleButton.text(hideButtonText);
				if (rootElement) {
					rootElement.classList.remove("list-switcher-collapsed");
				}
			},
			function hide() {
				$toggleButton.text(showButtonText);
				if (rootElement) {
					rootElement.classList.add("list-switcher-collapsed");
				}
			}));
			
		// Register a resize observer to see if we need to keep the
		// show/hide toggle visible
		var termList = rootElement.querySelector(':scope > .term-list');
		if (termList && window.ResizeObserver) {
			var resizeObserver = new ResizeObserver(function(entries) {
				if (entries[0] && entries[0].contentBoxSize[0]) {
					// Work out what the max-height would be, in pixels
					// As a hack, this value is stored in the CSS `bottom`
					// property, as `max-height` is only in place when
					// the list is collapsed, but we need to do this check
					// even when the list is not collapsed
					var maxHeightPx = parseFloat(getComputedStyle(rootElement).bottom);
					
					// If box height is less than its max height + 20 px, suppress
					// collapsibility. The 20 px buffer prevents the situation where
					// clicking "show more" expands the box by just a few pixels
					if (entries[0].contentBoxSize[0].blockSize <= maxHeightPx + 20) {
						$toggleElement.hide();
						if (rootElement.classList.contains("list-switcher-collapsed")) {
							rootElement.classList.remove("list-switcher-collapsed");
							rootElement.classList.add("list-switcher-collapsibility-suppressed");
						}
					} else {
						$toggleElement.show();
						if (rootElement.classList.contains("list-switcher-collapsibility-suppressed")) {
							rootElement.classList.remove("list-switcher-collapsibility-suppressed");
							rootElement.classList.add("list-switcher-collapsed");
						}
					}
				}
			});
			
			resizeObserver.observe(termList);
		}
	}
	
	// based on [[User:Erutuon/scripts/semhide.js]], [[User:Jberkel/semhide.js]],
	// [[User:Ungoliant_MMDCCLXIV/synshide.js]]
	function setupNyms(index, dlTag) {
		// [[Wiktionary:Semantic relations]]
		var relationClasses = [ "synonym", "antonym", "hypernym", "hyponym", "meronym",
								"holonym", "troponym", "comeronym", "coordinate-term",
								"near-synonym", "imperfective", "perfective", "alternative-form" ];
		
		var relations = $(dlTag).find("dd > .nyms").get().filter(
			function(element) {
				return Array.prototype.some.call(element.classList, function (className) {
					if (relationClasses.indexOf(className) !== -1) {
						element.dataset.relationClass = className;
						return true;
					}
				});
			});
		
		function setupToggle(elements, category, visibleByDefault) {
			if (elements.length === 0) return null;
			var toggler = $("<a>").attr("role", "button").attr("tabindex", "0");
			var text = elements.map(function (e) {
				var linkCount = e.querySelectorAll("span[lang]").length;
				return e.dataset.relationClass.replace("-", " ") +
						(linkCount > 1 ? "s" : "");
			}).join(", ");
			
			function show() {
				toggler.text(text + nbsp + "▲");
				$(dlTag).show();
				$(elements).show();
			}
			function hide() {
				toggler.text(text + nbsp + "▼");
				if ($(dlTag).children().length === elements.length) {
					$(dlTag).hide();
				} else {
					$(elements).hide();
				}
			}
		
			$(dlTag).before($("<span>")
				.addClass("nyms-toggle")
				.attr("data-nosnippet", "")
				.append(toggler)
				.css("margin-left", "5px"));
				
			toggler.click(window.VisibilityToggles.register(category, show, hide, visibleByDefault));
		}
		
		var synonyms = relations.filter(function (e) {
			return ["synonym", "antonym", "near-synonym", "coordinate-term", "alternative-form"].indexOf(e.dataset.relationClass) !== -1;
		});
		var other = relations.filter(function (e) { return synonyms.indexOf(e) === -1; });
		
		setupToggle(synonyms, "synonyms", true /* show by default  */);
		setupToggle(other, "semantic relations");
	}
	
	
	function setupUsageExampleCollapses(index, dlTag) {
		var usexTags = $(dlTag).find("dd > .h-usage-example").get();
		
		function setupToggle(elements, category, visibleByDefault) {
			if (elements.length === 0) return null;
			var toggler = $("<a>").attr("role", "button").attr("tabindex", "0");
			
			function show() {
				toggler.text(category + nbsp + "▲");
				$(dlTag).show();
				$(elements).show();
			}
			function hide() {
				toggler.text(category + nbsp + "▼");
				if ($(dlTag).children().length === elements.length) {
					$(dlTag).hide();
				} else {
					$(elements).hide();
				}
			}
		
			$(dlTag).before($("<span>")
				.addClass("nyms-toggle")
				.append(toggler)
				.css("margin-left", "5px"));
				
			toggler.click(window.VisibilityToggles.register(category, show, hide, visibleByDefault));
		}
		
		var collocations = usexTags.filter(function (e) {
			return $(e).hasClass("collocation");
		});
		var usexes = usexTags.filter(function (e) { return collocations.indexOf(e) === -1; });
		
		setupToggle(usexes, "usage examples", true /* show by default */);
		setupToggle(collocations, "collocations", true /* show by default */);
	}
	
	
	window.createNavToggle = createNavToggle;
	window.setupHiddenQuotes = setupHiddenQuotes;
	window.viewSwitching = viewSwitching;
	window.getToggleCategory = getToggleCategory;
	
	/* == Apply four functions defined above == */
	mw.hook("wikipage.content").add(function($content) {
		// NavToggles
		$(".NavFrame", $content).each(function(){
			createNavToggle(this);
		});
		$(".inflection-table-collapsible", $content).each(function(){
			createNavToggleForInflectionTable(this);
		});
		
		// order nyms -> usexes -> quotes, to match the conventional order in entries
		
		// synonyms and such under definitions
		// if (mw.config.get("wgNamespaceNumber") === 0) {
			$("dl:has(dd > .nyms)", $content).each(setupNyms);
		// }
		
		// usage examples and collocations
		var namespaceNumber = mw.config.get("wgNamespaceNumber");
		if (window.defaultVisibilityTogglesForUsageExamples) {
			if (namespaceNumber === 0) {
				$("ol > li dl:has(dd > .h-usage-example)", $content).each(setupUsageExampleCollapses);
			}
		}
	
		// quotes
		if (namespaceNumber === 0 || namespaceNumber === 100 || namespaceNumber === 118) {
			// First, find all the ordered lists, i.e. all the series of definitions.
			$("ol > li", $content).each(function(){
				setupHiddenQuotes(this);
			});
		}
	
		//view switching
		$(".vsSwitcher", $content).each(function(){
			viewSwitching(this);
		});
	
		// list switching
		$(".list-switcher", $content).each(function () {
			enableListSwitchGeneric(this);
		});
	});
	
	jQuery(mw).on("LivePreviewDone", function (ev, sels) {
		var ols = jQuery(sels.join(",")).find("ol");
		for (var i = 0; i < ols.length; i++) {
			for (var j = 0; j < ols[i].childNodes.length; j++) {
				var li = ols[i].childNodes[j];
				if (li.nodeName.toUpperCase() == "LI") {
					setupHiddenQuotes(li);
				}
			}
		}
	});
})();