MediaWiki:Common.js: Difference between revisions

From Melvor Idle
(Ensure darkModeNoticeElem always defined regardless of previous dismissal of prompt (in case user clicks 'Dark Mode' links))
(Fix dark mode notice not attaching to DOM if previously dismissed)
Line 608: Line 608:
newDiv.style.cssText = 'display:none; max-width:100%; width:100%; text-align:center;';
newDiv.style.cssText = 'display:none; max-width:100%; width:100%; text-align:center;';
darkModeNoticeElem = newDiv;
darkModeNoticeElem = newDiv;
const cnElem = document.getElementById('mw-content');
if (cnElem !== null) {
cnElem.insertBefore(darkModeNoticeElem, cnElem.firstChild);
}
if (!darkModeNoticeDismissed) {
if (!darkModeNoticeDismissed) {
const cnElem = document.getElementById('mw-content');
if (cnElem !== null) {
cnElem.insertBefore(darkModeNoticeElem, cnElem.firstChild);
}
darkModeNoticeToggle();
darkModeNoticeToggle();
if (darkModeSchemeQuery.addEventListener) {
if (darkModeSchemeQuery.addEventListener) {

Revision as of 18:16, 15 December 2022

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

/** <pre>
 * [NOTE: The below Javascript was stolen from https://oldschool.runescape.wiki/w/MediaWiki:Gadget-highlightTable-core.js on 10/28/2020 by User:Falterfire]
 * highlightTable.js
 *
 * Description:
 * Adds highlighting to tables
 *
 * History:
 * - 1.0: Row highlighting                        - Quarenon
 * - 1.1: Update from pengLocations.js v1.0       - Quarenon
 * - 2.0: pengLocations v2.1, Granular cookie     - Saftzie
 * - 2.1: Made compatible with jquery.tablesorter - Cqm
 * - 2.2: Switch to localStorage                  - Cqm
 * - 3.0: Allow cell highlighting                 - mejrs
 *
 * @todo Allow the stored data to be coupled to the table in question. Currently the data is stored
 *       on the page itself, so if any tables are shuffled, the highlighting doesn't follow. For
 *       the same reason tables hosted on other pages are not synchronized.
 */

/**
 * DATA STORAGE STRUCTURE
 * ----------------------
 *
 * In its raw, uncompressed format, the stored data is as follows:
 * {
 *     hashedPageName1: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ],
 *     hashedPageName2: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ]
 * }
 *
 * Where `hashedPageNameX` is the value of wgPageName passed through our `hashString` function,
 * the arrays of numbers representing tables on a page (from top to bottom) and the numbers
 * representing whether a row is highlighted or not, depending on if it is 1 or 0 respectively.
 *
 * During compression, these numbers are collected into groups of 6 and converted to base64.
 * For example:
 *
 *   1. [0, 1, 0, 1, 0, 1]
 *   2. 0x010101             (1 + 4 + 16 = 21)
 *   3. BASE_64_URL[21]      (U)
 *
 * Once each table's rows have been compressed into strings, they are concatenated using `.` as a
 * delimiter. The hashed page name (which is guaranteed to be 8 characters long) is then prepended
 * to this string to look something like the following:
 *
 *   XXXXXXXXab.dc.ef
 *
 *
 * The first character of a hashed page name is then used to form the object that is actually
 * stored. As the hashing function uses hexadecimal, this gives us 16 possible characters (0-9A-Z).
 *
 * {
 *     A: ...
 *     B: ...
 *     C: ...
 *     // etc.
 * }
 *
 * The final step of compression is to merge each page's data together under it's respective top
 * level key. this is done by concatenation again, separated by a `!`.
 *
 * The resulting object is then converted to a string and persisted in local storage. When
 * uncompressing data, simply perform the following steps in reverse.
 *
 * For the implementation of this algorithm, see:
 * - `compress`
 * - `parse`
 * - `hashString`
 *
 * Note that while rows could theoretically be compressed further by using all ASCII characters,
 * eventually we'd start using characters outside printable ASCII which makes debugging painful.
 */

/*jshint bitwise:false, camelcase:true, curly:true, eqeqeq:true, es3:false,
    forin:true, immed:true, indent:4, latedef:true, newcap:true,
    noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
    undef:true, unused:true, strict:true, trailing:true,
    browser:true, devel:false, jquery:true,
    onevar:true
*/
mw.loader.using(['oojs-ui-core', 'oojs-ui.styles.icons-interactions']).done(function() {
	(function($, mw) {
	    'use strict';
	
	    var hasLocalStorage = function(){
	        try {
	            localStorage.setItem('test', 'test')
	            localStorage.removeItem('test')
	            return true
	        } catch (e) {
	            return false
	        }
	    }
	
	    // constants
	    var STORAGE_KEY = 'mi:lightTable',
	        TABLE_CLASS = 'lighttable',
	        LIGHT_ON_CLASS = 'highlight-on',
	        MOUSE_OVER_CLASS = 'highlight-over',
	        BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
	        PAGE_SEPARATOR = '!',
	        TABLE_SEPARATOR = '.',
	        CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
	        UINT32_MAX = 0xffffffff,
	
	        self = {
	            /*
	             * Stores the current uncompressed data for the current page.
	             */
	            data: null,
	
	            /*
	             * Perform initial checks on the page and browser.
	             */
	            init: function() {
	                var $tables = $('table.' + TABLE_CLASS),
	                    hashedPageName = self.hashString(mw.config.get('wgPageName'));
	
	                // check we have some tables to interact with
	                if (!$tables.length) {
	                    return;
	                }
	                // check the browser supports local storage
	                if (!hasLocalStorage()) {
	                    return;
	                }
	
	                self.data = self.load(hashedPageName, $tables.length);
	                self.initTables(hashedPageName, $tables);
	            },
	
	            /*
	             * Initialise table highlighting.
	             *
	             * @param hashedPageName The current page name as a hash.
	             * @param $tables A list of highlightable tables on the current page.
	             */
	            initTables: function(hashedPageName, $tables) {
	                $tables.each(function(tIndex) {
	                    var $this = $(this),
	                        // data cells
	                        $cells = $this.find('td'),
	                        $rows = $this.find('tr:has(td)'),
	                        // don't rely on headers to find number of columns      
	                        // count them dynamically
	                        columns = 1,
	                        tableData = self.data[tIndex],
	                        mode = 'cells';
	
	                    // Switching between either highlighting rows or cells
	                    if (!$this.hasClass('individual')) {
	                        mode = 'rows';
	                        $cells = $rows;
	                    }
	
	                    // initialise rows if necessary
	                    while ($cells.length > tableData.length) {
	                        tableData.push(0);
	                    }
	
	                    // counting the column count
	                    // necessary to determine colspan of reset button
	                    $rows.each(function() {
	                        var $this = $(this);
	                        columns = Math.max(columns, $this.children('th,td').length);
	                    });
	
	                    $cells.each(function(cIndex) {
	                        var $this = $(this),
	                            cellData = tableData[cIndex];
	
	                        // forbid highlighting any cells/rows that have class nohighlight
	                        if (!$this.hasClass('nohighlight')) {
	                            // initialize highlighting based on the cookie
	                            self.setHighlight($this, cellData);
	
	                            // set mouse events
	                            $this
	                                .mouseover(function() {
	                                    self.setHighlight($this, 2);
	                                })
	                                .mouseout(function() {
	                                    self.setHighlight($this, tableData[cIndex]);
	                                })
	                                .click(function(e) {
	                                    // don't toggle highlight when clicking links
	                                    if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
	                                        // 1 -> 0
	                                        // 0 -> 1
	                                        tableData[cIndex] = 1 - tableData[cIndex];
	
	                                        self.setHighlight($this, tableData[cIndex]);
	                                        self.save(hashedPageName);
	                                    }
	                                });
	                        }
	                    });
	
	                    // add a button for reset
	                    var button = new OO.ui.ButtonWidget({
	                        label: 'Clear selection',
	                        icon: 'clear',
	                        title: 'Removes all highlights from the table',
	                        classes: ['ht-reset'] // this class is targeted by other gadgets, be careful removing it
	                    });
	
	
	                    button.$element.click(function() {
	                        $cells.each(function(cIndex) {
	                            tableData[cIndex] = 0;
	                            self.setHighlight($(this), 0);
	                        });
	
	                        self.save(hashedPageName, $tables.length);
	                    });
	
	                    $this.append(
	                        $('<tfoot>')
	                            .append(
	                                $('<tr>')
	                                    .append(
	                                        $('<th>')
	                                            .attr('colspan', columns)
	                                            .append(button.$element)
	                                    )
	                            )
	                    );
	                });
	            },
	
	            /*
	             * Change the cell background color based on mouse events.
	             *
	             * @param $cell The cell element.
	             * @param val The value to control what class to add (if any).
	             *            0 -> light off (no class)
	             *            1 -> light on
	             *            2 -> mouse over
	             */
	            setHighlight: function($cell, val) {
	                $cell.removeClass(MOUSE_OVER_CLASS);
	                $cell.removeClass(LIGHT_ON_CLASS);
	
	                switch (val) {
	                    // light on
	                    case 1:
	                        $cell.addClass(LIGHT_ON_CLASS);
	                        break;
	
	                    // mouse-over
	                    case 2:
	                        $cell.addClass(MOUSE_OVER_CLASS);
	                        break;
	                }
	            },
	
	            /*
	             * Merge the updated data for the current page into the data for other pages into local storage.
	             *
	             * @param hashedPageName A hash of the current page name.
	             */
	            save: function(hashedPageName) {
	                // load the existing data so we know where to save it
	                var curData = localStorage.getItem(STORAGE_KEY),
	                    compressedData;
	
	                if (curData === null) {
	                    curData = {};
	                } else {
	                    curData = JSON.parse(curData);
	                    curData = self.parse(curData);
	                }
	
	                // merge in our updated data and compress it
	                curData[hashedPageName] = self.data;
	                compressedData = self.compress(curData);
	
	                // convert to a string and save to localStorage
	                compressedData = JSON.stringify(compressedData);
	                localStorage.setItem(STORAGE_KEY, compressedData);
	            },
	
	            /*
	             * Compress the entire data set using tha algoritm documented at the top of the page.
	             *
	             * @param data The data to compress.
	             *
	             * @return the compressed data.
	             */
	            compress: function(data) {
	                var ret = {};
	
	                Object.keys(data).forEach(function(hashedPageName) {
	                    var pageData = data[hashedPageName],
	                        pageKey = hashedPageName.charAt(0);
	
	                    if (!ret.hasOwnProperty(pageKey)) {
	                        ret[pageKey] = {};
	                    }
	
	                    ret[pageKey][hashedPageName] = [];
	
	                    pageData.forEach(function(tableData) {
	                        var compressedTableData = '',
	                            i, j, k;
	
	                        for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
	                            k = tableData[6 * i];
	
	                            for (j = 1; j < 6; j += 1) {
	                                k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
	                            }
	
	                            compressedTableData += BASE_64_URL.charAt(k);
	                        }
	
	                        ret[pageKey][hashedPageName].push(compressedTableData);
	                    });
	
	                    ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(TABLE_SEPARATOR);
	                });
	
	                Object.keys(ret).forEach(function(pageKey) {
	                    var hashKeys = Object.keys(ret[pageKey]),
	                        hashedData = [];
	
	                    hashKeys.forEach(function(key) {
	                        var pageData = ret[pageKey][key];
	                        hashedData.push(key + pageData);
	                    });
	
	                    hashedData = hashedData.join(PAGE_SEPARATOR);
	                    ret[pageKey] = hashedData;
	                });
	
	                return ret;
	            },
	
	            /*
	             * Get the existing data for the current page.
	             *
	             * @param hashedPageName A hash of the current page name.
	             * @param numTables The number of tables on the current page. Used to ensure the loaded
	             *                  data matches the number of tables on the page thus handling cases
	             *                  where tables have been added or removed. This does not check the
	             *                  amount of rows in the given tables.
	             *
	             * @return The data for the current page.
	             */
	            load: function(hashedPageName, numTables) {
	                var data = localStorage.getItem(STORAGE_KEY),
	                    pageData;
	
	                if (data === null) {
	                    pageData = [];
	                } else {
	                    data = JSON.parse(data);
	                    data = self.parse(data);
	
	                    if (data.hasOwnProperty(hashedPageName)) {
	                        pageData = data[hashedPageName];
	                    } else {
	                        pageData = [];
	                    }
	                }
	
	                // if more tables were added
	                // add extra arrays to store the data in
	                // also populates if no existing data was found
	                while (numTables > pageData.length) {
	                    pageData.push([]);
	                }
	
	                // if tables were removed, remove data from the end of the list
	                // as there's no way to tell which was removed
	                while (numTables < pageData.length) {
	                    pageData.pop();
	                }
	
	                return pageData;
	            },
	
	            /*
	             * Parse the compressed data as loaded from local storage using the algorithm desribed
	             * at the top of the page.
	             *
	             * @param data The data to parse.
	             *
	             * @return the parsed data.
	             */
	            parse: function(data) {
	                var ret = {};
	
	                Object.keys(data).forEach(function(pageKey) {
	                    var pageData = data[pageKey].split(PAGE_SEPARATOR);
	
	                    pageData.forEach(function(tableData) {
	                        var hashedPageName = tableData.substr(0, 8);
	
	                        tableData = tableData.substr(8).split(TABLE_SEPARATOR);
	                        ret[hashedPageName] = [];
	
	                        tableData.forEach(function(rowData, index) {
	                            var i, j, k;
	
	                            ret[hashedPageName].push([]);
	
	                            for (i = 0; i < rowData.length; i += 1) {
	                                k = BASE_64_URL.indexOf(rowData.charAt(i));
	
	                                // input validation
	                                if (k < 0) {
	                                    k = 0;
	                                }
	
	                                for (j = 5; j >= 0; j -= 1) {
	                                    ret[hashedPageName][index][6 * i + j] = (k & 0x1);
	                                    k >>= 1;
	                                }
	                            }
	                        });
	                    });
	
	                });
	
	                return ret;
	            },
	
	            /*
	             * Hash a string into a big endian 32 bit hex string. Used to hash page names.
	             *
	             * @param input The string to hash.
	             *
	             * @return the result of the hash.
	             */
	            hashString: function(input) {
	                var ret = 0,
	                    table = [],
	                    i, j, k;
	
	                // guarantee 8-bit chars
	                input = window.unescape(window.encodeURI(input));
	
	                // calculate the crc (cyclic redundancy check) for all 8-bit data
	                // bit-wise operations discard anything left of bit 31
	                for (i = 0; i < 256; i += 1) {
	                    k = (i << 24);
	
	                    for (j = 0; j < 8; j += 1) {
	                        k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
	                    }
	                    table[i] = k;
	                }
	
	                // the actual calculation
	                for (i = 0; i < input.length; i += 1) {
	                    ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
	                }
	
	                // make negative numbers unsigned
	                if (ret < 0) {
	                    ret += UINT32_MAX;
	                }
	
	                // 32-bit hex string, padded on the left
	                ret = '0000000' + ret.toString(16).toUpperCase();
	                ret = ret.substr(ret.length - 8);
	
	                return ret;
	            }
	        };
	
	    $(self.init);
	
	    /*
	    // sample data for testing the algorithm used
	    var data = {
	        // page1
	        '0FF47C63': [
	            [0, 1, 1, 0, 1, 0],
	            [0, 1, 1, 0, 1, 0, 1, 1, 1],
	            [0, 0, 0, 0, 1, 1, 0, 0]
	        ],
	        // page2
	        '02B75ABA': [
	            [0, 1, 0, 1, 1, 0],
	            [1, 1, 1, 0, 1, 0, 1, 1, 0],
	            [0, 0, 1, 1, 0, 0, 0, 0]
	        ],
	        // page3
	        '0676470D': [
	            [1, 0, 0, 1, 0, 1],
	            [1, 0, 0, 1, 0, 1, 0, 0, 0],
	            [1, 1, 1, 1, 0, 0, 1, 1]
	        ]
	    };
	
	    console.log('input', data);
	
	    var compressedData = self.compress(data);
	    console.log('compressed', compressedData);
	
	    var parsedData = self.parse(compressedData);
	    console.log(parsedData);
	    */

	}(this.jQuery, this.mediaWiki));
});

// </pre>
//[This is the end of the section stolen from https://oldschool.runescape.wiki/w/MediaWiki:Gadget-highlightTable-core.js]

/* Sets the top property for stickyHeader tables */
function setStickyHeaderTop() {
  const stickyTables = document.getElementsByClassName('stickyHeader');
  const headStyles = getComputedStyle(document.getElementById('mw-header-container'));
  var headHeight = document.getElementById('mw-header-container').offsetHeight;
  if (headStyles !== undefined && headStyles.position === 'static') {
    headHeight = 0;
  }
  for (var i = 0; i < stickyTables.length; i++) {
    const firstRow = stickyTables[i].getElementsByClassName('headerRow-0');
    const secondRow = stickyTables[i].getElementsByClassName('headerRow-1');
    var firstHeight = 0;
    if (firstRow.length > 0) {
      firstHeight = firstRow[0].offsetHeight;
      const firstHeaders = firstRow[0].getElementsByTagName('th');
      for (var j = 0; j < firstHeaders.length; j++) {
        firstHeaders[j].style.top = headHeight + 'px';
      }
      if (secondRow.length > 0) {
        const secondHeaders = secondRow[0].getElementsByTagName('th');
        var secondHeight = headHeight + firstHeight - 1;
        for (var j = 0; j < secondHeaders.length; j++) {
          secondHeaders[j].style.top = secondHeight + 'px';
        }
      }
    }
  }
}

const darkModeSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
const darkModeNoticeDismissed = (localStorage.getItem('mi-darkMode-noticeDismissed') === null ? 'false' : localStorage.getItem('mi-darkMode-noticeDismissed')) !== 'false';
const darkModeActive = (localStorage.getItem('darkMode') === null ? 'false' : localStorage.getItem('darkMode')) !== 'false';
var darkModeNoticeElem = undefined;

function darkModeNoticeVisible(show) {
	darkModeNoticeElem.innerHTML = darkModeNoticeHTML();
	darkModeNoticeElem.style.display = (show ? 'block' : 'none');
}

function darkModeNoticeToggle() {
	// If the legacy dark mode flag & the user's scheme preference conflict, then display a notice,
	// but only if the user hasn't already acknowledged that notice
	const visibility = !darkModeNoticeDismissed && ((darkModeActive && !darkModeSchemeQuery.matches) || (!darkModeActive && darkModeSchemeQuery.matches))
	darkModeNoticeVisible(visibility);
}

function darkModeNoticeDismiss() {
	localStorage.setItem('mi-darkMode-noticeDismissed', 'true');
	darkModeNoticeVisible(false);
	// Returning false prevents default onclick behaviour
	return false;
}

function darkModeNoticeHTML() {
	const schemeDesiredText = (darkModeActive ? 'dark' : 'light');
	return 'Themes on the wiki have changed. To continue using ' + schemeDesiredText + ' mode please change your device or browser theme settings:<br/><a class="external text" href="https://support.google.com/chrome/answer/9275525?co=GENIE.Platform%3DAndroid&oco=1">Android</a> • <a class="external text" href="https://support.apple.com/en-gb/HT210332">iPhone & iPad</a> • <a class="external text" href="https://support.apple.com/en-gb/HT208976">Mac</a> • <a class="external text" href="https://support.google.com/chrome/answer/9275525?co=GENIE.Platform%3DDesktop&oco=1">Chrome</a> • <a class="external text" href="https://support.mozilla.org/en-US/kb/change-web-appearance-settings-firefox">Firefox</a> • <a class="external text" href="https://support.microsoft.com/en-gb/microsoft-edge/use-the-dark-theme-in-microsoft-edge-9b74617b-f542-77ed-033b-1a5cfb17a2df">Edge</a><br/><div style="text-align:right"><a href="#" onclick="darkModeNoticeDismiss();">Dismiss this message</a></div>';
}

$(document).ready(function () {
	// Table sticky headers
	const elemSticky = document.getElementsByClassName('stickyHeader');
	if (elemSticky.length > 0) {
		// Sticky headers do not function well when Tabber containers/article tags.
		// Therefore identify any stickyHeader tables within these containers 
		//  and remove the stickyHeader class
		const elemArticle = document.getElementsByTagName('article');
		if (elemArticle.length > 0) {
			for (var kS = 0; kS < elemSticky.length; kS++) {
				for (var kA = 0; kA < elemArticle.length; kA++) {
					const eSticky = elemSticky[kS];
					const eArticle = elemArticle[kA];
					if (eArticle.contains(eSticky)) {
						eSticky.classList.remove('stickyHeader');
					}
				}
			}
		}
		setStickyHeaderTop();
		$(window).resize(setStickyHeaderTop);
	}

	// Dark mode
	// Initialize element
	var newDiv = document.createElement('div');
	newDiv.classList.toggle('noticebox');
	newDiv.style.cssText = 'display:none; max-width:100%; width:100%; text-align:center;';
	darkModeNoticeElem = newDiv;
	const cnElem = document.getElementById('mw-content');
	if (cnElem !== null) {		
		cnElem.insertBefore(darkModeNoticeElem, cnElem.firstChild);
	}

	if (!darkModeNoticeDismissed) {
		darkModeNoticeToggle();
		if (darkModeSchemeQuery.addEventListener) {
			darkModeSchemeQuery.addEventListener('change', darkModeNoticeToggle);	
		}
	}
});
	

// Add dark mode links to "Wiki tools" and personal tools menus
$.when(mw.loader.using(['mediawiki.util']), $.ready).then( function() {
	var dmLinkTools = mw.util.addPortletLink('p-tb', '#', 'Dark mode', 't-darkmode', 'Toggle between a dark and light theme', null, '#t-specialpages');
	var dmLinkPersonal = mw.util.addPortletLink('p-personal', '#', 'Dark mode', 'pt-darkmode', 'Toggle between a dark and light theme', null, null);
	dmLinkPersonal.style.marginTop = '0.5rem';
	$(dmLinkTools).on('click', function(e) {
		e.preventDefault();
		darkModeNoticeVisible(true);
	});
	$(dmLinkPersonal).on('click', function(e) {
		e.preventDefault();
		darkModeNoticeVisible(true);
	});
});