|
Tags: Replaced Undo Reverted |
| Line 1: |
Line 1: |
| /**
| | mw.loader.load( 'https://www.mediawiki.org/w/load.php?modules=ext.gadget.Global-Calculator' ); |
| * On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]]
| |
| * License: Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License
| |
| * Canonical source: https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js
| |
| *
| |
| * This script is designed with security in mind. Possible security risks:
| |
| * * Having a formula that executes JS
| |
| * ** To prevent this we do not use eval(). Instead we parse the formula with our own parser into an abstract tree that we evaluate by walking through it
| |
| * * Form submission & DOM clobbering - we prefix the name (and id) attribute of all fields to prevent conflicts
| |
| * * Style injection - we take the style attribute from an existing element that was sanitized by MW. We do not take style from a data attribute.
| |
| * * Client-side DoS - Formulas aren't evaluated without user interaction. Formulas have a max length. Max number of widgets per page. Ultimately, easy to revert slow formulas just like any other vandalism.
| |
| * * Prototype pollution - We use objects with null prototypes and also reject fields named __proto__ just in case.
| |
| *
| |
| * Essentially the code works by replacing certain <span> tags with <input>, parsing a custom formula language, setting up a dependency graph based on identifiers, and re-evaluating formulas on change.
| |
| */
| |
| (function () {
| |
| | |
| var mathFuncs = [ 'abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'cbrt', 'ceil', 'cos', 'cosh', 'exp', 'expm1', 'floor', 'hypot',
| |
| 'log', 'log10', 'log2', 'log1p', 'max', 'min', 'pow', 'random', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc', 'clz32' ];
| |
| var otherFuncs = [
| |
| 'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive', 'ifequal', 'round', 'jsround', 'not', 'and', 'or', 'bool', 'ifless', 'iflessorequal', 'ifgreater', 'ifgreaterorequal', 'ifbetween', 'xor',
| |
| 'index', 'switch', 'radiogroup', 'bitand', 'bitor', 'bitxor', 'bitnot', 'bitleftshift', 'bitlogicrightshift', 'bitarithrightshift', 'if', 'getclick', 'timer', 'timeriterations', 'timertime'
| |
| ];
| |
| var allFuncs = mathFuncs.concat(otherFuncs);
| |
| | |
| var convertFloat = function ( f ) {
| |
| if ( typeof f === 'number' ) {
| |
| return f;
| |
| }
| |
| f = f.replace( /×\s?10/, 'e' );
| |
| return parseFloat( f.replace( /[×⁰¹²³⁴⁵⁶⁷⁸⁹⁻]/g, function (m) {
| |
| return ({'⁰': 0,'¹': 1,'²': 2,'³': 3,'⁴': 4,'⁵': 5,'⁶': 6,'⁷': 7,'⁸': 8,'⁹': 9, '⁻': "-", "×": "e"})[m];
| |
| } ) );
| |
| };
| |
| | |
| // Start parser code.
| |
| var Numb = function(n) {
| |
| if ( typeof n === 'number' ) {
| |
| this.value = n;
| |
| }
| |
| this.value = parseFloat(n);
| |
| };
| |
| Numb.prototype.toString = function () { return 'Number<' + this.value + '>'; };
| |
| | |
| var Ident = function(n) {
| |
| this.value = n;
| |
| };
| |
| Ident.prototype.toString = function () { return 'IDENT<' + this.value + '>'; };
| |
| | |
| var Operator = function(val, args) {
| |
| this.op = val;
| |
| this.args = args;
| |
| };
| |
|
| |
| var Null = function() { };
| |
| | |
| var Parser = function( text ) {
| |
| this.text = text;
| |
| };
| |
| | |
| var terminals = {
| |
| 'IDENT': /^[a-zA-Z_][a-zA-Z0-9_]*/,
| |
| 'NUMBER': /^-?[0-9]+(?:\.[0-9]+)?(?:[eE][0-9]+|×10⁻?[⁰¹²³⁴⁵⁶⁷⁸⁹]+)*/,
| |
| 'WS': /^\s*/,
| |
| 'PLUSMINUS': /^[+-]/,
| |
| 'pi': /^(?:pi|π)(?![A-z_0-9-])/i,
| |
| 'true': /^(?:true)(?![A-z_0-9-])/,
| |
| 'false': /^(?:false)(?![A-z_0-9-])/,
| |
| 'epsilon': /^EPSILON(?![A-z_0-9-])/,
| |
| 'Infinity': /^Infinity(?![A-z_0-9-])/i,
| |
| '-Infinity': /^Infinity(?![A-z_0-9-])/i,
| |
| 'NaN': /^NaN(?![A-z_0-9-])/i,
| |
| 'MULTIPLYDIV': /^[*\/%×÷]/i,
| |
| };
| |
| | |
| Parser.prototype = {
| |
| check: function(id) {
| |
| if ( terminals[id] ) {
| |
| return !!(this.text.match(terminals[id]));
| |
| }
| |
| return this.text.startsWith( id );
| |
| },
| |
| consume: function(id) {
| |
| if ( terminals[id] ) {
| |
| var res = this.text.match(terminals[id]);
| |
| this.text = this.text.substring( res[0].length );
| |
| return res[0];
| |
| }
| |
| if ( this.text.startsWith( id ) ) {
| |
| this.text = this.text.substring(id.length);
| |
| return id;
| |
| }
| |
| throw new Error( "Expected " + id + " near " + this.text.substring(0,15) );
| |
| },
| |
| | |
| parse: function () {
| |
| if ( this.text === undefined || this.text === '' ) return new Null();
| |
| this.consume( 'WS' );
| |
| res = this.expression();
| |
| if( this.text.length !== 0 ) {
| |
| throw new Error( "Unexpected end of formula. Perhaps you forgot to close a parenthesis or are using an invalid function." );
| |
| }
| |
| return res;
| |
| },
| |
| | |
| expression: function () {
| |
| var text2, res, res2;
| |
| res = this.term();
| |
| this.consume( 'WS' );
| |
| while ( this.check( "PLUSMINUS" )) {
| |
| var op = this.consume( "PLUSMINUS" );
| |
| this.consume( 'WS' );
| |
| res2 = this.term();
| |
| this.consume( 'WS' );
| |
| res = new Operator( op, [ res, res2 ] );
| |
| }
| |
| return res;
| |
| },
| |
| | |
| argList: function () {
| |
| var args = [];
| |
| this.consume( 'WS' );
| |
| if ( this.check( ')' ) ) {
| |
| this.consume( ')' );
| |
| return args;
| |
| }
| |
| args[args.length] = this.expression();
| |
| this.consume( 'WS' );
| |
| | |
| while ( this.check( ',' ) ) {
| |
| this.consume( ',' );
| |
| this.consume( 'WS' );
| |
| args[args.length] = this.expression();
| |
| }
| |
| this.consume( 'WS' );
| |
| this.consume( ')' );
| |
| return args;
| |
| },
| |
| term: function () {
| |
| var text2, res, res2;
| |
| res = this.factor();
| |
| this.consume( 'WS' );
| |
| while ( this.check( "MULTIPLYDIV" )) {
| |
| var op = this.consume( "MULTIPLYDIV" );
| |
| if ( op === '×' ) op = '*';
| |
| if ( op === '÷' ) op = '/';
| |
| this.consume( 'WS' );
| |
| res2 = this.factor();
| |
| this.consume( 'WS' );
| |
| res = new Operator( op, [ res, res2 ] );
| |
| }
| |
| return res;
| |
| },
| |
| factor: function () {
| |
| this.consume( 'WS' );
| |
| var res;
| |
| if ( this.check( 'pi' ) ) {
| |
| this.consume( 'pi' );
| |
| return new Numb( Math.PI );
| |
| }
| |
| if ( this.check( 'true' ) ) {
| |
| this.consume( 'true' );
| |
| return new Numb( 1 );
| |
| }
| |
| if ( this.check( 'false' ) ) {
| |
| this.consume( 'false' );
| |
| return new Numb( 0 );
| |
| }
| |
| if ( this.check( 'Infinity' ) ) {
| |
| this.consume( 'Infinity' );
| |
| return new Numb( Infinity );
| |
| }
| |
| if ( this.check( '-Infinity' ) ) {
| |
| this.consume( '-Infinity' );
| |
| return new Numb( -Infinity );
| |
| }
| |
| if ( this.check( 'NaN' ) ) {
| |
| this.consume( 'NaN' );
| |
| return new Numb( NaN );
| |
| }
| |
| if ( this.check( 'epsilon' ) ) {
| |
| this.consume( 'epsilon' );
| |
| return new Numb( Number.EPSILON );
| |
| }
| |
| | |
| for ( var i in allFuncs ) {
| |
| if ( this.check( allFuncs[i] + '(' ) ) {
| |
| this.consume(allFuncs[i] + '(');
| |
| var argList = this.argList();
| |
| return new Operator( allFuncs[i], argList );
| |
| }
| |
| }
| |
| | |
| if ( this.check( 'IDENT' ) ) {
| |
| return new Ident( this.consume( 'IDENT' ) );
| |
| }
| |
| if ( this.check( 'NUMBER' ) ) {
| |
| return new Numb( convertFloat( this.consume( 'NUMBER' ) ) );
| |
| }
| |
| | |
| if ( this.check( '(' ) ) {
| |
| this.consume( '(' );
| |
| this.consume( 'WS' );
| |
| res = this.expression();
| |
| this.consume( 'WS' );
| |
| this.consume( ')' );
| |
| return res;
| |
| }
| |
| // unary minus
| |
| if ( this.check( '-' ) ) {
| |
| this.consume( '-' );
| |
| this.consume( 'WS' );
| |
| if ( this.check( '-' ) ) {
| |
| throw new Error( "Double unary minus without parenthesis not allowed near " + this.text.substring(0,15));
| |
| }
| |
| res = this.factor();
| |
| return new Operator( '*', [new Numb(-1), res] );
| |
| }
| |
| throw new Error( "Expected to see a term (e.g. an identifier, number, function, opening parenthesis, etc) near " + this.text.substring(0,15));
| |
| },
| |
| };
| |
| // End parser code.
| |
| | |
| // Based on https://floating-point-gui.de/errors/comparison/
| |
| var almostEquals = function( a, b ) {
| |
| var absA = Math.abs(a);
| |
| var absB = Math.abs(b);
| |
| var diff = Math.abs(a-b);
| |
| var epsilon = Number.EPSILON; /// Not sure if this is a good value for epsilon
| |
| var minNormal = Math.pow( 2, -1022 );
| |
| if ( a===b) {
| |
| return true;
| |
| }
| |
| // Min normal of double = 2^-1022
| |
| if ( a==0 || b==0 || absA+absB < minNormal ) {
| |
| return diff < epsilon * minNormal;
| |
| }
| |
| | |
| return diff / Math.min((absA + absB), Number.MAX_VALUE) < epsilon;
| |
| };
| |
| | |
| // elm: Element to get value of
| |
| // Mapping to process value with (usually this.mappingInput[element item id])
| |
| var getValueOfElm = function (elm, mapping) {
| |
| var val;
| |
| if ( elm.hasAttribute( 'data-calculator-real-value' ) ) {
| |
| // We have a non-formatted value set. Use that and skip any mappings.
| |
| return parseFloat( elm.getAttribute( 'data-calculator-real-value' ) );
| |
| } else if ( elm.tagName === 'INPUT' || elm.tagName === 'SELECT' ) {
| |
| if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
| |
| // Radio/checkboxes are not allowed to have mappings
| |
| return elm.checked ? convertFloat( elm.value ) : 0;
| |
| }
| |
| val = elm.value;
| |
| } else {
| |
| val = elm.textContent;
| |
| }
| |
| if ( typeof val === 'string' ) {
| |
| val = val.trim();
| |
| var mapVals = [ val, val.toLowerCase() ];
| |
| for ( var k = 0; k < mapVals.length; k++ ) {
| |
| if (mapping && typeof mapping[mapVals[k]] === 'number' ) {
| |
| return mapping[mapVals[k]];
| |
| } else if ( mapping && [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[mapVals[k]] ) !== -1 ) {
| |
| return parseFloat( mapping[mapVals[k]] );
| |
| }
| |
| }
| |
| }
| |
| | |
| return convertFloat( val );
| |
| };
| |
| | |
| // Evaluate the value of an AST at runtime.
| |
| var evaluate = function( ast ) {
| |
| var that = this;
| |
| var elmList = this.elmList;
| |
| var i, then, ielse, res, elm;
| |
| if ( ast instanceof Numb ) {
| |
| return ast.value;
| |
| }
| |
| if ( ast instanceof Ident ) {
| |
| elm = elmList[ast.value];
| |
| if ( elm === undefined ) {
| |
| console.log( "Calculator: Reference to '" + ast.value + "' but there is no field by that name" );
| |
| return NaN;
| |
| }
| |
| return getValueOfElm( elm, this.mappingInput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] );
| |
| }
| |
| if ( ast instanceof Operator ) {
| |
| // Special case, index() does not directly eval first arg
| |
| // index() is like an array index operator (sort of)
| |
| // It evaluates its second argument and concats that to the first identifier
| |
| if ( 'index' === ast.op ) {
| |
| if ( ast.args.length < 2 || !( ast.args[0] instanceof Ident ) ) {
| |
| return NaN;
| |
| }
| |
| var indexValue = Math.floor(this.evaluate( ast.args[1] ));
| |
| if ( !Number.isSafeInteger( indexValue ) || indexValue < 0 ) {
| |
| return NaN;
| |
| }
| |
| res = elmList[ast.args[0].value + indexValue];
| |
| if ( res === undefined ) {
| |
| return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : NaN;
| |
| }
| |
| return getValueOfElm( res, this.mappingInput[res.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] );
| |
| }
| |
| // Get the value of the checked element of a radio group or NaN if none checked
| |
| // Unlike most things, first args identifies the radio group name and not an id.
| |
| if ( ast.op === 'radiogroup' ) {
| |
| if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) {
| |
| return NaN;
| |
| }
| |
| var radioName = ast.args[0].value;
| |
| elm = this.parent.querySelector( 'input:checked[type=radio][name=' + CSS.escape( 'calcgadget-' + this.rand + '-' + radioName ) + ']' );
| |
| if ( !elm ) {
| |
| return NaN;
| |
| }
| |
| return this.evaluate( new Ident( elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) ) );
| |
| }
| |
| if ( ast.op === 'getclick' ) {
| |
| if ( ast.args.length < 1 || !( ast.args[0] instanceof Ident ) ) {
| |
| return NaN;
| |
| }
| |
| switch( ast.args[0].value.toLowerCase() ) {
| |
| case 'x':
| |
| return this.clickX;
| |
| case 'prevx':
| |
| return this.clickPrevX;
| |
| case 'prevpercentx':
| |
| return this.clickPrevPercentX;
| |
| case 'startpercentx':
| |
| return this.clickStartPercentX;
| |
| case 'startx':
| |
| return this.clickStartX;
| |
| case 'percentx':
| |
| return this.clickPercentX;
| |
| case 'y':
| |
| return this.clickY;
| |
| case 'percenty':
| |
| return this.clickPercentY;
| |
| case 'prevy':
| |
| return this.clickPrevY;
| |
| case 'prevpercenty':
| |
| return this.clickPrevPercentY;
| |
| case 'starty':
| |
| return this.clickStartY;
| |
| case 'startpercenty':
| |
| return this.clickStartPercentY;
| |
| case 'inprogress':
| |
| return this.clickInProgress;
| |
| case 'type':
| |
| // 1 = start, 2 - move, 3 = done, 4 = cancel
| |
| return this.clickType;
| |
| default:
| |
| return NaN;
| |
| }
| |
| }
| |
| // Check if a specific timer is running.
| |
| // timer() returns 0 or 1 if any timer
| |
| // timer( timerName) checks for that sepcific timer
| |
| // timer(timerName, trueValue, falseValue ) returns specific value.
| |
| if ( ast.op === 'timer' ) {
| |
| if (!this.repeatInfo) {
| |
| return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0;
| |
| }
| |
| if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
| |
| return ast.args.length >= 2 ? this.evaluate( ast.args[1] ) : 1;
| |
| }
| |
| return ast.args.length >= 3 ? this.evaluate( ast.args[2] ) : 0;
| |
| }
| |
| if ( ast.op === 'timeriterations' ) {
| |
| if (!this.repeatInfo) {
| |
| return NaN;
| |
| }
| |
| if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
| |
| return this.repeatInfo.iterationsSoFar;
| |
| }
| |
| return NaN;
| |
| }
| |
| if ( ast.op === 'timertime' ) {
| |
| if (!this.repeatInfo) {
| |
| return NaN;
| |
| }
| |
| if (ast.args.length < 1 || (ast.args[0] instanceof Ident && ast.args[0].value === this.repeatInfo.id) ) {
| |
| return (Date.now() - this.repeatInfo.timeStart)/1000;
| |
| }
| |
| return NaN;
| |
| }
| |
| // Start of functions that evaluate their arguments.
| |
| evaledArgs = ast.args.map( function (i) { return that.evaluate( i ) } );
| |
| if ( mathFuncs.indexOf(ast.op) !== -1 ) {
| |
| return Math[ast.op].apply( Math, evaledArgs );
| |
| }
| |
| if ( 'coalesce' === ast.op ) {
| |
| for ( var k = 0; k < evaledArgs.length; k++ ) {
| |
| if ( !isNaN( evaledArgs[k] ) ) {
| |
| return evaledArgs[k];
| |
| }
| |
| }
| |
| return NaN;
| |
| }
| |
| if ( 'ifzero' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
| |
| ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
| |
| return almostEquals( evaledArgs[0], 0 ) ? then : ielse;
| |
| }
| |
| // Opposite of ifzero
| |
| if ( 'if' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
| |
| ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
| |
| return !almostEquals( evaledArgs[0], 0 ) ? then : ielse;
| |
| }
| |
| if ( 'ifequal' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
| |
| ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
| |
| return almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
| |
| }
| |
| if ( 'iffinite' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
| |
| ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
| |
| return isFinite( evaledArgs[0] ) ? then : ielse;
| |
| }
| |
| if ( 'ifnan' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
| |
| ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
| |
| return isNaN( evaledArgs[0] ) ? then : ielse;
| |
| }
| |
| if ( 'ifpositive' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 2 ? 1 : evaledArgs[1];
| |
| ielse = evaledArgs.length < 3 ? 0 : evaledArgs[2];
| |
| return evaledArgs[0] >= 0 ? then : ielse;
| |
| }
| |
| | |
| // I am a bit unsure what the proper thing to do about floating point rounding issues here
| |
| // These will err on the side of returning true given rounding error. People can use ifpositive() if they
| |
| // need precise.
| |
| if ( 'ifless' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
| |
| ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
| |
| return evaledArgs[0] < evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
| |
| }
| |
| if ( 'ifgreater' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
| |
| ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
| |
| return evaledArgs[0] > evaledArgs[1] && !almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
| |
| }
| |
| if ( 'iflessorequal' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
| |
| ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
| |
| return evaledArgs[0] <= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
| |
| }
| |
| if ( 'ifgreaterorequal' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 3 ? 1 : evaledArgs[2];
| |
| ielse = evaledArgs.length < 4 ? 0 : evaledArgs[3];
| |
| return evaledArgs[0] >= evaledArgs[1] || almostEquals( evaledArgs[0], evaledArgs[1] ) ? then : ielse;
| |
| }
| |
| // Should this use almostEquals???
| |
| if ( 'ifbetween' === ast.op ) {
| |
| if ( evaledArgs.length < 3 ) {
| |
| return NaN;
| |
| }
| |
| then = evaledArgs.length < 4 ? 1 : evaledArgs[3];
| |
| ielse = evaledArgs.length < 5 ? 0 : evaledArgs[4];
| |
| return evaledArgs[0] >= evaledArgs[1] && evaledArgs[0] <= evaledArgs[2] ? then : ielse;
| |
| }
| |
| if ( 'bool' === ast.op ) {
| |
| if ( evaledArgs.length !== 1 ) {
| |
| return NaN;
| |
| }
| |
| return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 0 : 1;
| |
| }
| |
| if ( 'not' === ast.op ) {
| |
| if ( evaledArgs.length !== 1 ) {
| |
| return NaN;
| |
| }
| |
| return isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ? 1 : 0;
| |
| }
| |
| if ( 'xor' === ast.op ) {
| |
| if ( evaledArgs.length !== 2 ) {
| |
| return NaN;
| |
| }
| |
| if (
| |
| ( ( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) && !( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) ) ||
| |
| ( ( isNaN( evaledArgs[1] ) || almostEquals( evaledArgs[1], 0.0 ) ) && !( isNaN( evaledArgs[0] ) || almostEquals( evaledArgs[0], 0.0 ) ) )
| |
| ) {
| |
| return 1;
| |
| } else {
| |
| return 0;
| |
| }
| |
| }
| |
| // Short circuit like in lua
| |
| if ( 'and' === ast.op ) {
| |
| for ( i = 0; i < evaledArgs.length; i++ ) {
| |
| if ( isNaN( evaledArgs[i] ) || almostEquals( evaledArgs[i], 0.0 ) ) {
| |
| return evaledArgs[i];
| |
| }
| |
| }
| |
| return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 1;
| |
| }
| |
| if ( 'or' === ast.op ) {
| |
| for ( i = 0; i < evaledArgs.length; i++ ) {
| |
| if ( !isNaN( evaledArgs[i] ) && !almostEquals( evaledArgs[i], 0.0 ) ) {
| |
| return evaledArgs[i];
| |
| }
| |
| }
| |
| return evaledArgs.length >= 1 ? evaledArgs[evaledArgs.length-1] : 0;
| |
| }
| |
| | |
| // Bitwise operators (numbers treated like 32bit integer). The binary ones can take any number of args.
| |
| if ( 'bitand' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| res = evaledArgs[0];
| |
| for ( i = 1; i < evaledArgs.length; i++ ) {
| |
| res = res & evaledArgs[i];
| |
| }
| |
| return res;
| |
| }
| |
| if ( 'bitor' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| res = evaledArgs[0];
| |
| for ( i = 1; i < evaledArgs.length; i++ ) {
| |
| res = res | evaledArgs[i];
| |
| }
| |
| return res;
| |
| }
| |
| if ( 'bitxor' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| res = evaledArgs[0];
| |
| for ( i = 1; i < evaledArgs.length; i++ ) {
| |
| res = res ^ evaledArgs[i];
| |
| }
| |
| return res;
| |
| }
| |
| // Bitwise operators that don't take infinite amount of ops.
| |
| if ( 'bitnot' === ast.op ) {
| |
| if ( evaledArgs.length < 1 ) {
| |
| return NaN;
| |
| }
| |
| return ~evaledArgs[0];
| |
| }
| |
| if ( 'bitleftshift' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| return evaledArgs[0] << evaledArgs[1];
| |
| }
| |
| if ( 'bitlogicrightshift' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| return evaledArgs[0] >>> evaledArgs[1];
| |
| }
| |
| if ( 'bitarithrightshift' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| return evaledArgs[0] >> evaledArgs[1];
| |
| }
| |
| // switch(value,2,valueFor2,5,valueFor5,...,valueIfNoneMatch)
| |
| // select the arg where the test <= value.
| |
| if ( 'switch' === ast.op ) {
| |
| if ( evaledArgs.length < 2 ) {
| |
| return NaN;
| |
| }
| |
| var defaultVal = NaN;
| |
| if ( evaledArgs.length % 2 === 0 ) {
| |
| // Last arg is default if even number of args.
| |
| defaultVal = evaledArgs[evaledArgs.length-1];
| |
| }
| |
| for ( i = 1; i < evaledArgs.length-1; i+=2 ) {
| |
| if ( evaledArgs[0] <= evaledArgs[i] || almostEquals( evaledArgs[i], evaledArgs[0] ) ) {
| |
| return evaledArgs[i+1];
| |
| }
| |
| }
| |
| return defaultVal;
| |
| }
| |
| | |
| // js Math.round is weird. Do our own version. Based on https://stackoverflow.com/questions/11832914/how-to-round-to-at-most-2-decimal-places-if-necessary
| |
| // This does round-half away from zero, with floating point error correction.
| |
| if ( 'round' === ast.op ) {
| |
| var decimals = evaledArgs.length >= 2 ? evaledArgs[1] : 0;
| |
| var p = Math.pow( 10, decimals );
| |
| var n = (evaledArgs[0] * p) * (1 + Number.EPSILON);
| |
| return Math.round(n)/p;
| |
| }
| |
| | |
| // In case anyone wants normal js round.
| |
| if ( ast.op === 'jsround' ) {
| |
| return Math.round.apply( Math, evaledArgs );
| |
| }
| |
| | |
| if ( evaledArgs.length !== 2 ) {
| |
| throw new Error( "Unexpected number of args for " + ast.op );
| |
| }
| |
| if ( ast.op === '*' ) return evaledArgs[0]*evaledArgs[1];
| |
| if ( ast.op === '/' ) return evaledArgs[0]/evaledArgs[1];
| |
| if ( ast.op === '+' ) return evaledArgs[0]+evaledArgs[1];
| |
| if ( ast.op === '-' ) return evaledArgs[0]-evaledArgs[1];
| |
| if ( ast.op === '%' ) return evaledArgs[0]%evaledArgs[1];
| |
| throw new Error( "Unrecognized operator " + ast.op );
| |
| }
| |
| return NaN;
| |
| };
| |
| | |
| // Start dependency graph code
| |
| | |
| var getIdentifiers = function( tree ) {
| |
| if ( tree instanceof Ident ) {
| |
| return new Set([tree.value]);
| |
| }
| |
| if ( tree instanceof Operator ) {
| |
| var res = new Set([]);
| |
| var i = 0;
| |
| if ( tree.op === 'index' && tree.args.length > 0 ) {
| |
| // Special case - index allows indirect references decided at runtime
| |
| // A future version might handle this more dynamically.
| |
| i++;
| |
| getIdentifiers( tree.args[0] ).forEach( function (x) { res.add(x + '*') } );
| |
| } else if ( (tree.op === 'radiogroup' ) && tree.args.length > 0 && tree.args[0] instanceof Ident ) {
| |
| i++;
| |
| res.add( '--radio-' + tree.args[0].value );
| |
| } else if ( (tree.op === 'timer' ) ) {
| |
| if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) {
| |
| i++;
| |
| res.add( '--timer-active-' + tree.args[0].value );
| |
| } else {
| |
| res.add( '--timer-active' );
| |
| }
| |
| } else if ( tree.op === 'timertime' || tree.op === 'timeriterations' ) {
| |
| res.add( '--timer-iteration' );
| |
| if ( tree.args.length > 0 && tree.args[0] instanceof Ident ) {
| |
| res.add( '--timer-iteration-' + tree.args[0].value );
| |
| } else {
| |
| res.add( '--timer-iteration' );
| |
| }
| |
| return res;
| |
| } else if ( tree.op === 'getclick' ) {
| |
| res.add( '--click' );
| |
| return res;
| |
| }
| |
| for ( ; i < tree.args.length; i++ ) {
| |
| getIdentifiers( tree.args[i] ).forEach( function (x) { res.add(x) } );
| |
| }
| |
| return res;
| |
| }
| |
| return new Set();
| |
| };
| |
| | |
| // e.g. if a=foo+1 then backlinks['foo'] = ['a',...]
| |
| var buildBacklinks = function (items, radioGroups) {
| |
| var backlinks = Object.create(null);
| |
| for ( var item in items ) {
| |
| var idents = getIdentifiers( items[item] );
| |
| // Set does not do for..in loops, and this version MW forbids for..of
| |
| idents.forEach( function (ident) {
| |
| if ( !backlinks[ident] ) {
| |
| backlinks[ident] = [];
| |
| }
| |
| backlinks[ident].push( item );
| |
| } );
| |
| }
| |
| // We use identifiers starting with -- as special purpose
| |
| // Each radio item should depend on each other, since if one gets checked the others get unchecked.
| |
| var identPrefix = '--radio-';
| |
| for ( var groupName in radioGroups ) {
| |
| if ( backlinks[identPrefix + groupName] == undefined ) {
| |
| backlinks[identPrefix + groupName] = [];
| |
| }
| |
| for ( var i = 0; i < radioGroups[groupName].length; i++ ) {
| |
| backlinks[identPrefix + groupName].push( radioGroups[groupName][i] );
| |
| if ( backlinks[radioGroups[groupName][i]] == undefined ) {
| |
| backlinks[radioGroups[groupName][i]] = [];
| |
| }
| |
| backlinks[radioGroups[groupName][i]].push( identPrefix + groupName );
| |
| }
| |
| }
| |
| return backlinks;
| |
| };
| |
| | |
| // End dependency graph code
| |
| | |
| // Start code that does setup and HTML interaction.
| |
| | |
| var setup = function ( $content ) {
| |
| // We allow users to group calculator widgets inside a <div class="calculator-container"> to scope
| |
| // ids just to that group. That way you can use a specific template multiple times on the same page.
| |
| // (Perhaps we should turn it into a <fieldset>?).
| |
| var containers = Array.from( $content.find( '.calculator-container' ) );
| |
| for ( var i = 0; i < containers.length; i++ ) {
| |
| var calc = new CalculatorWidgets( Array.from( containers[i].getElementsByClassName( 'calculator-field' ) ), containers[i] );
| |
| }
| |
| // Anything not in a container is scoped to the page.
| |
| var elms = Array.from( $content.find( '.calculator-field' ) );
| |
| new CalculatorWidgets( elms, $content[0] );
| |
| | |
| $content.find( '.calculator-field-label').replaceWith( function() {
| |
| var l = $( '<label>' );
| |
| // For accessibility reasons with codex, we sometimes have to make a label with no for attribute (e.g. radiogroups)
| |
| if ( this.dataset.for !== undefined ) {
| |
| if ( !this.dataset.for.match( /^(?:calcdisambig-\d+-)?calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) || !$content.find( '#' + $.escapeSelector( this.dataset.for ) ).length ) {
| |
| return this;
| |
| }
| |
| l.attr( 'for', this.dataset.for );
| |
| }
| |
| if ( this.id ) {
| |
| l.attr( 'id', this.id );
| |
| }
| |
| if ( this.title ) {
| |
| l.attr( 'title', this.title );
| |
| }
| |
| if ( this.style.cssText ) {
| |
| l.attr( 'style', this.style.cssText );
| |
| }
| |
| if ( this.classList.contains( 'cdx-label__label' ) ) {
| |
| mw.loader.load( '@wikimedia/codex' );
| |
| }
| |
| if ( this.className !== 'calculator-field-label' ) {
| |
| var extraClass = this.dataset.calculatorClass === undefined ? '' : ' ' + this.dataset.calculatorClass;
| |
| l.attr( 'class', this.className.replace( /(^| )calculator-field-label( |$)/g, ' ' ) + extraClass );
| |
| }
| |
| | |
| l.append( this.childNodes );
| |
| return l;
| |
| } );
| |
| };
| |
| | |
| var doStats = function () {
| |
| if ( window.calculatorStatsAlreadyDone !== true ) {
| |
| window.calculatorStatsAlreadyDone = true;
| |
| mw.track( 'stats.mediawiki_gadget_calculator_total' );
| |
| mw.track( 'stats.mediawiki_gadget_calculator_wiki_total', 1, { wiki: mw.config.get( 'wgDBname' ) } );
| |
| var pageName = encodeURIComponent( mw.config.get( 'wgTitle' ).replace( / /g, '_' ) );
| |
| // alphanumeric and underscore only.
| |
| pageName = pageName.replace( /[^a-zA-Z0-9_]/g, '_' );
| |
| mw.track( 'stats.mediawiki_gadget_calculator_page_total', 1, { wiki: mw.config.get( 'wgDBname' ), page: pageName, NS: mw.config.get( 'wgNamespaceNumber' ) } );
| |
| }
| |
| };
| |
| | |
| var createSelect = function( elm ) {
| |
| var sel = document.createElement( 'select' );
| |
| var mapping;
| |
| try {
| |
| mapping = JSON.parse( elm.dataset.calculatorMapping );
| |
| } catch(e) {
| |
| console.log( "Calculator: Error processing mapping of " + elm.id + ". " + e.message );
| |
| return sel;
| |
| }
| |
| if ( typeof mapping !== 'object' ) {
| |
| console.log( "Calculator: Error processing mapping of " + elm.id + ". unexpected type" );
| |
| return sel;
| |
| }
| |
| var opt;
| |
| for ( var i in mapping ) {
| |
| if ( typeof mapping[i] === 'object' ) {
| |
| var optgroup = document.createElement( 'optgroup' );
| |
| optgroup.label = i;
| |
| sel.appendChild( optgroup );
| |
| // Note: optgroup cannot be nested.
| |
| for ( var j in mapping[i] ) {
| |
| if ( typeof mapping[i][j] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i][j] ) !== -1 ) {
| |
| opt = document.createElement( 'option' );
| |
| opt.value = mapping[i][j];
| |
| opt.appendChild( document.createTextNode( j ) );
| |
| optgroup.appendChild( opt );
| |
| }
| |
| }
| |
| } else if ( typeof mapping[i] === 'number' || [ 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) {
| |
| opt = document.createElement( 'option' );
| |
| opt.value = mapping[i];
| |
| opt.appendChild( document.createTextNode( i ) );
| |
| sel.appendChild( opt );
| |
| }
| |
| }
| |
| return sel;
| |
| };
| |
| | |
| var CalculatorWidgets = function( elms, parent ) {
| |
| this.parent = parent;
| |
| this.rand = Math.floor(Math.random()*1000000000);
| |
| this.itemList = Object.create(null);
| |
| this.elmList = Object.create(null);
| |
| this.backlinks = Object.create(null);
| |
| this.inProgressRefresh = undefined;
| |
| this.clickX = this.clickY = this.clickPercentX = this.clickPercentY = NaN;
| |
| this.clickPrevX = this.clickPrevY = this.clickPrevPercentX = this.clickPrevPercentY = NaN;
| |
| this.clickStartX = this.clickStartY = this.clickStartPercentX = this.clickStartPercentY = NaN;
| |
| this.clickType = NaN;
| |
| this.clickInProgress = 0;
| |
| this.repeatInfo = null;
| |
| var that = this;
| |
| var radioGroups = Object.create(null);
| |
| this.buttonInfo = Object.create(null);
| |
| this.mappingInput = Object.create(null);
| |
| this.mappingOutput = Object.create(null);
| |
| this.repeatInfo = null;
| |
| | |
| if (elms.length > 200) {
| |
| console.log( "Too many calculator widgets on page" );
| |
| return;
| |
| }
| |
| for ( var i in elms ) {
| |
| var elm = elms[i];
| |
| var formula = elm.dataset.calculatorFormula;
| |
| if ( formula && formula.length > 2000 ) {
| |
| console.log( "Skipping element with too long formula" );
| |
| continue;
| |
| }
| |
| var type = elm.dataset.calculatorType;
| |
| var readonly = !!elm.dataset.calculatorReadonly;
| |
| var id = elm.id.toString();
| |
| if ( id.match( /^(?:calcdisambig-\d+-)?calculator-field-__proto__$/ ) || elm.dataset.calculatorName === '__proto__' ) {
| |
| // be paranoid
| |
| throw new Error( "Invalid calculator id: " + id );
| |
| }
| |
| if ( !id ) {
| |
| id = 'calculator-field-auto' + Math.floor( Math.random()*10000000 ) + 'unnamed';
| |
| }
| |
| if ( this.parent.classList.contains( 'calculator-container' ) && document.getElementById( id ) !== elm ) {
| |
| // There is a duplicate id, and we are not at the top level.
| |
| // This is not ideal, as you can't reference it using other attributes (e.g. aria-labelledby, aria-describedby) or link targets, but at least we won't label the wrong thing.
| |
| var labelsToUpdate = this.parent.querySelectorAll( ":scope .calculator-field-label[data-for=\"" + CSS.escape( id ) + "\"]" );
| |
| id = 'calcdisambig-' + this.rand + '-' + id;
| |
| for ( var l = 0; l < labelsToUpdate.length; l++ ) {
| |
| labelsToUpdate[l].dataset.for = id;
| |
| }
| |
| }
| |
| var defaultVal = ("" + elm.textContent).trim();
| |
| if ( type === undefined || !id.match( /^(?:calcdisambig-\d+-)?calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) {
| |
| console.log( "Skipping " + id );
| |
| continue;
| |
| }
| |
| | |
| var formulaAST;
| |
| try {
| |
| formulaAST = (new Parser( formula )).parse();
| |
| } catch( e ) {
| |
| console.log( "Error parsing formula of " + id + ": " + e.message + ". Formula given:" + formula );
| |
| continue;
| |
| }
| |
| if ( elm.className.match( /(^| )cdx-/ ) || ( elm.dataset.calculatorClass && elm.dataset.calculatorClass.match( /(^| )cdx-/ ) ) ) {
| |
| // The input is using CSS-only codex modules. Unfortunately i think we have to load the whole thing.
| |
| // we are going to have a flash of unstyled content no matter what we do since we are converting elements at load time, so don't worry about that.
| |
| mw.loader.load( '@wikimedia/codex' );
| |
| }
| |
| if ( type !== 'plain' && type !== 'passthru' ) {
| |
| var input = type === 'select' ? createSelect( elm ) : document.createElement( 'input' );
| |
| input.className = 'calculator-field-live';
| |
| if ( elm.className !== 'calculator-field' ) input.className += ' ' + elm.className.replace( /(^| )calculator-field($| )/g, ' ' );
| |
| input.readOnly = readonly;
| |
| // If defaultVal is empty, we start the textbox value out as empty instead of NaN.
| |
| if ( defaultVal !== '' ) {
| |
| input.value = convertFloat(defaultVal);
| |
| }
| |
| input.style.cssText = elm.style.cssText; // This should be safe because elm's css was sanitized by MW
| |
| if ( elm.dataset.calculatorSize ) {
| |
| var size = parseInt( elm.dataset.calculatorSize );
| |
| input.size = size;
| |
| // Browsers are pretty inconsistent so also set as css
| |
| // Firefox shows a number selector that seems to always be 20px wide regardless of font.
| |
| // Chrome shows the selector only on hover.
| |
| input.style.width = type === 'number' ? "calc( " + size + 'ch' + ' + 20px)': size + 'ch';
| |
| }
| |
| // Add css class, but only if the gadget is enabled.
| |
| if ( elm.dataset.calculatorClass ) input.className += ' ' + elm.dataset.calculatorClass;
| |
| if ( elm.dataset.calculatorSize ) input.size = elm.dataset.calculatorSize;
| |
| if ( elm.dataset.calculatorMax ) input.max = elm.dataset.calculatorMax;
| |
| if ( elm.dataset.calculatorMin ) input.min = elm.dataset.calculatorMin;
| |
| if ( elm.dataset.calculatorPlaceholder ) input.placeholder = elm.dataset.calculatorPlaceholder;
| |
| if ( elm.dataset.calculatorStep ) input.step = elm.dataset.calculatorStep;
| |
| if ( elm.dataset.calculatorPrecision ) input.dataset.calculatorPrecision = elm.dataset.calculatorPrecision;
| |
| if ( elm.dataset.calculatorExponentialPrecision ) input.dataset.calculatorExponentialPrecision = elm.dataset.calculatorExponentialPrecision;
| |
| if ( elm.dataset.calculatorDecimals ) input.dataset.calculatorDecimals = elm.dataset.calculatorDecimals;
| |
| if ( elm.dataset.calculatorNanText ) input.dataset.calculatorNanText = elm.dataset.calculatorNanText;
| |
| if ( type === 'radio' ) {
| |
| // Name is primarily for radio groups. Prefix to prevent dom clobbering or in case it ends up in a form somehow. Add rand to make unique between scoping containers
| |
| if ( elm.dataset.calculatorName ) input.name = 'calcgadget-' + this.rand + '-' + elm.dataset.calculatorName;
| |
| if ( radioGroups[elm.dataset.calculatorName] === undefined ) {
| |
| radioGroups[elm.dataset.calculatorName] = [];
| |
| }
| |
| radioGroups[elm.dataset.calculatorName][radioGroups[elm.dataset.calculatorName].length] = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
| |
| }
| |
| if ( elm.dataset.calculatorInputmode ) input.inputMode = elm.dataset.calculatorInputmode;
| |
| if ( elm.dataset.calculatorEnterkeyhint ) input.enterKeyHint = elm.dataset.calculatorEnterkeyhint;
| |
| if ( elm.getAttribute( 'aria-describedby' ) !== null ) input.setAttribute( 'aria-describedby', elm.getAttribute( 'aria-describedby' ) );
| |
| if ( elm.getAttribute( 'aria-labelledby' ) !== null ) input.setAttribute( 'aria-labelledby', elm.getAttribute( 'aria-labelledby' ) );
| |
| if ( elm.getAttribute( 'aria-label' ) !== null ) input.setAttribute( 'aria-label', elm.getAttribute( 'aria-label' ) );
| |
| if ( elm.getAttribute( 'aria-owns' ) !== null ) input.setAttribute( 'aria-owns', elm.getAttribute( 'aria-owns' ) );
| |
| if ( elm.getAttribute( 'aria-flowto' ) !== null ) input.setAttribute( 'aria-flowto', elm.getAttribute( 'aria-flowto' ) );
| |
| if ( elm.getAttribute( 'role' ) !== null ) input.setAttribute( 'role', elm.getAttribute( 'role' ) );
| |
| // To support only setting the role if gadget is enabled
| |
| if ( elm.dataset.calculatorAriaRole ) input.setAttribute( 'role', elm.dataset.calculatorAriaRole );
| |
| if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) input.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic );
| |
| if ( elm.dataset.calculatorAriaRelevant ) input.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant );
| |
| | |
| | |
| if ( ['off', 'polite', 'assertive'].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) {
| |
| input.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive );
| |
| } else {
| |
| // We treat invalid values as set nothing, which inherits from parent which is different than setting off. This allows user to override
| |
| // We also set a default of polite for cells with a formula
| |
| if ( formula ) {
| |
| input.setAttribute( 'aria-live', 'polite' );
| |
| }
| |
| }
| |
| if ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' || type === "hidden" || type === "range" ) {
| |
| input.type = type;
| |
| }
| |
| if ( type === 'select' ) {
| |
| if ( elm.dataset.calculatorValue ) {
| |
| // Allow people to override the default value for select with attribute, as the value might not be a good non-js fallback.
| |
| input.value = elm.dataset.calculatorValue;
| |
| }
| |
| }
| |
| if ( type === 'radio' || type === 'checkbox' ) {
| |
| input.onchange = this.changeHandler.bind(this); // some browsers dont fire oninput for checkboxes/radio
| |
| if ( elm.dataset.calculatorChecked === 'true' || elm.dataset.calculatorChecked === '1' ) {
| |
| input.checked = true;
| |
| } else if ( elm.dataset.calculatorChecked === 'false' || elm.dataset.calculatorChecked === '0' ) {
| |
| input.checked = false;
| |
| } else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) {
| |
| // If all else fails, use the default value to decide if it is checked
| |
| input.checked = true;
| |
| }
| |
| // Allow user to set a different checked value other than 1. This will be the value when checked by human, but not neccesarily if by formula
| |
| // We use a specific data-calculator-value if set and true, otherwise we use the defaultVal (text firstChild) if true, and 1 as a last resort.
| |
| var checkValue = convertFloat( elm.dataset.calculatorValue || '' );
| |
| if ( !isNaN( checkValue ) && !almostEquals( checkValue, 0 ) ) {
| |
| input.value = checkValue;
| |
| input.dataset.calculatorValue = checkValue;
| |
| } else if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) {
| |
| input.value = defaultVal;
| |
| input.dataset.calculatorValue = defaultVal;
| |
| } else {
| |
| input.value = 1;
| |
| input.dataset.calculatorValue = 1;
| |
| }
| |
| }
| |
| if ( elm.dataset.calculatorMapping && type === 'text') {
| |
| this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) );
| |
| }
| |
| | |
| input.id = id;
| |
| elm.replaceWith( input );
| |
| elm = input;
| |
| input.addEventListener( 'input', this.changeHandler.bind(this) );
| |
| } else {
| |
| elm.classList.remove( 'calculator-field' );
| |
| elm.classList.add( 'calculator-field-live' );
| |
| if ( elm.dataset.calculatorClass ) elm.className += ' ' + elm.dataset.calculatorClass;
| |
| if ( ['off', 'polite', 'assertive' ].indexOf( elm.dataset.calculatorAriaLive ) !== -1 ) {
| |
| elm.setAttribute( 'aria-live', elm.dataset.calculatorAriaLive );
| |
| } else if ( formula && type !== 'passthru' && !elm.dataset.calculatorAriaLive ) {
| |
| // Announce plain outputs by default, but allow user to override. Treat invalid value as do not set, since not setting is slightly different then the value off.
| |
| elm.setAttribute( 'aria-live', 'polite' );
| |
| }
| |
| if ( ['true', 'false'].indexOf( elm.dataset.calculatorAriaAtomic ) !== -1 ) elm.setAttribute( 'aria-atomic', elm.dataset.calculatorAriaAtomic );
| |
| // Support either directly setting role or using data attribute, in case people only want to set the role in the JS case.
| |
| if ( elm.dataset.calculatorAriaRole ) elm.setAttribute( 'role', elm.dataset.calculatorAriaRole );
| |
| if ( elm.dataset.calculatorAriaRelevant ) elm.setAttribute( 'aria-relevant', elm.dataset.calculatorAriaRelevant );
| |
| | |
| if ( elm.id === '' ) {
| |
| elm.id = id;
| |
| }
| |
| if ( elm.dataset.calculatorMapping && type === 'plain') {
| |
| this.processMapping( elm.dataset.calculatorMapping, id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' ) );
| |
| }
| |
| }
| |
| var itemId = id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
| |
| this.itemList[itemId] = formulaAST;
| |
| this.elmList[itemId] = elm;
| |
| }
| |
| this.backlinks = buildBacklinks( this.itemList, radioGroups );
| |
| if ( this.parent.dataset.calculatorRefreshOnLoad && this.parent.dataset.calculatorRefreshOnLoad === 'true' ) {
| |
| this.inProgressRefresh = Object.create(null);
| |
| var unsetBusy = this.setBusy();
| |
| this.refresh( Object.keys( this.itemList ).filter( function (a) { return !(that.itemList[a] instanceof Null); } ) );
| |
| this.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| }
| |
| this.setupButtons();
| |
| };
| |
| | |
| // MappingText is a json blob of {"some string": number}
| |
| // Used to map between numbers <-> strings during input/output.
| |
| CalculatorWidgets.prototype.processMapping = function ( mappingText, itemId ) {
| |
| var mapping;
| |
| try {
| |
| mapping = JSON.parse( mappingText );
| |
| } catch(e) {
| |
| console.log( "Calculator: Error processing mapping of " + itemId + ". " + e.message );
| |
| return;
| |
| }
| |
| if ( typeof mapping !== 'object' ) {
| |
| console.log( "Calculator: Error processing mapping of " + itemId + ". unexpected type" );
| |
| return;
| |
| }
| |
| | |
| this.mappingInput[itemId] = mapping;
| |
| var mapOut = Object.create(null);
| |
| for (var i in mapping) {
| |
| if ( typeof mapping[i] === 'number' || [ 'default', 'Infinity', '-Infinity', 'NaN' ].indexOf( mapping[i] ) !== -1 ) {
| |
| mapOut[mapping[i]] = i;
| |
| }
| |
| }
| |
| this.mappingOutput[itemId] = mapOut;
| |
| };
| |
| | |
| CalculatorWidgets.prototype.stopRepeat = function () {
| |
| if ( this.repeatInfo ) {
| |
| if ( this.repeatInfo.cancelIdentifier ) {
| |
| // If until param is immediately true, the interval may not have been set yet.
| |
| window.clearInterval( this.repeatInfo.cancelIdentifier );
| |
| }
| |
| var id = this.repeatInfo.id;
| |
| this.repeatInfo = null;
| |
| this.inProgressRefresh = Object.create(null);
| |
| var unsetBusy = this.setBusy();
| |
| this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] );
| |
| this.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| }
| |
| this.repeatInfo = null;
| |
| };
| |
| | |
| CalculatorWidgets.prototype.startRepeat = function (maxIterations, id) {
| |
| if ( this.repeatInfo ) {
| |
| console.log( "Calculator: Trying to start a repeat when one is already in progress. This should not happen" );
| |
| }
| |
| this.repeatInfo = {
| |
| maxIterations: maxIterations,
| |
| iterationsSoFar: 0,
| |
| cancelIdentifier: null,
| |
| timeStart: Date.now(),
| |
| id: id
| |
| };
| |
| // Not 100% clear if we should immediately refresh or wait for after first iteration.
| |
| if ( this.inProgressRefresh ) {
| |
| console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
| |
| } else {
| |
| this.inProgressRefresh = Object.create(null);
| |
| }
| |
| var unsetBusy = this.setBusy();
| |
| this.refresh( [ '--timer-active', '--timer-active-' + id, '--timer-iteration', '--timer-iteration-' + id ] );
| |
| this.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| };
| |
| | |
| CalculatorWidgets.prototype.incrementRepeatIteration = function (until) {
| |
| this.repeatInfo.iterationsSoFar++;
| |
| if ( this.repeatInfo.iterationsSoFar > this.repeatInfo.maxIterations ) {
| |
| this.stopRepeat();
| |
| return false;
| |
| }
| |
| if ( until ) {
| |
| var untilRes = this.evaluate( until );
| |
| if ( !isNaN( untilRes ) && !almostEquals( untilRes, 0 ) ) {
| |
| this.stopRepeat();
| |
| return false;
| |
| }
| |
| }
| |
| // Update fields that depend on the iteration number
| |
| var id = this.repeatInfo.id;
| |
| if ( this.inProgressRefresh ) {
| |
| console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
| |
| } else {
| |
| this.inProgressRefresh = Object.create(null);
| |
| }
| |
| var unsetBusy = this.setBusy();
| |
| this.refresh( [ '--timer-iteration', '--timer-iteration-' + id ] );
| |
| this.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| return true;
| |
| };
| |
| | |
| var convertEventTypeToNumber = function ( type ) {
| |
| switch( type ) {
| |
| case 'pointerdown':
| |
| return 1;
| |
| case 'pointermove':
| |
| return 2;
| |
| case 'pointerup':
| |
| case 'click':
| |
| return 3;
| |
| case 'pointerleave':
| |
| return 4;
| |
| default:
| |
| throw new Error( "Unrecognized event type: " + type );
| |
| }
| |
| };
| |
| | |
| var updateClick = function(calc) {
| |
| if ( calc.inProgressRefresh ) {
| |
| console.log( "Calculator: Refresh already in progress. This probably indicates a bug" );
| |
| } else {
| |
| calc.inProgressRefresh = Object.create(null);
| |
| }
| |
| var unsetBusy = calc.setBusy();
| |
| calc.refresh( [ '--click' ] );
| |
| calc.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| };
| |
| | |
| // Handler for click events on button.
| |
| var makeButtonCallback = function ( calc, forElms, formulaASTs, repeat, maxIterations, toggle, until ) {
| |
| return function buttonCallback (event) {
| |
| if ( event.type !== 'click' ) {
| |
| if ( !event.isPrimary || ( !calc.clickInProgress && event.type !== 'pointerdown' ) ) {
| |
| return; // Skip secondary pointers.
| |
| }
| |
| }
| |
| event.preventDefault();
| |
| doStats();
| |
| var unsetBusy = calc.setBusy(); // stopping previous repeats might have side effects so set busy.
| |
| if ( toggle && calc.repeatInfo && calc.repeatInfo.id === event.currentTarget.id ) {
| |
| // If we click a toggle button a second time, all we do is stop the repeat.
| |
| calc.stopRepeat();
| |
| unsetBusy();
| |
| return;
| |
| }
| |
| if ( maxIterations !== undefined || repeat !== undefined ) {
| |
| // Only one repeating callback allowed at a time.
| |
| calc.stopRepeat();
| |
| if ( maxIterations > 1 ) {
| |
| // Start a new repeat
| |
| calc.startRepeat( maxIterations, event.currentTarget.id );
| |
| }
| |
| }
| |
| var doStuff = function(event) {
| |
| var unsetBusyInner = calc.setBusy();
| |
| var stillGoing = true;
| |
| if ( calc.repeatInfo ) {
| |
| stillGoing = calc.incrementRepeatIteration(until);
| |
| }
| |
| if ( stillGoing ) {
| |
| for ( var i = 0; i < forElms.length; i++ ) {
| |
| if ( !formulaASTs[i] || !forElms[i] ) {
| |
| console.log( "Skipping button update due to invalid formula or for attribute" );
| |
| continue;
| |
| }
| |
| var res = calc.evaluate( formulaASTs[i] );
| |
| calc.setValue( forElms[i], res );
| |
| forElms[i].dispatchEvent( new InputEvent( 'input' ) );
| |
| }
| |
| }
| |
| unsetBusyInner();
| |
| };
| |
| if ( maxIterations === undefined || maxIterations >= 1 ) {
| |
| // click coordinates can be negative or > 100% if user clicked border
| |
| if ( calc.clickInProgress ) {
| |
| calc.clickPrevX = calc.clickX;
| |
| calc.clickPrevY = calc.clickY;
| |
| calc.clickPrevPercentX = calc.clickPercentX;
| |
| calc.clickPrevPercentY = calc.clickPercentY;
| |
| }
| |
| if ( event.type === 'pointerdown' ) {
| |
| // This ensures better consistency between mouse and touchscreen.
| |
| event.currentTarget.setPointerCapture( event.pointerId );
| |
| }
| |
| if ( event.type !== 'pointerleave' ) {
| |
| // The coordinates returned for pointerleave events are inconsistent.
| |
| // Especially in firefox when a context menu is opened from right click.
| |
| calc.clickX = event.offsetX;
| |
| calc.clickY = event.offsetY;
| |
| calc.clickPercentX = 100*event.offsetX/event.currentTarget.clientWidth;
| |
| calc.clickPercentY = 100*event.offsetY/event.currentTarget.clientHeight;
| |
| calc.clickInProgress = 1;
| |
| }
| |
| calc.clickType = convertEventTypeToNumber( event.type );
| |
| if ( event.type === 'click' || event.type === 'pointerdown' ) {
| |
| calc.clickPrevX = calc.clickStartX = calc.clickX;
| |
| calc.clickPrevY = calc.clickStartY = calc.clickY;
| |
| calc.clickPrevPercentX = calc.clickStartPercentX = calc.clickPercentX;
| |
| calc.clickPrevPercentY = calc.clickStartPercentY = calc.clickPercentY;
| |
| }
| |
| updateClick(calc);
| |
| doStuff();
| |
| // We are done with the click events.
| |
| if ( calc.clickType >= 3 ) {
| |
| calc.clickX = calc.clickY = calc.clickPercentX = calc.clickPercentY = NaN;
| |
| calc.clickPrevX = calc.clickPrevY = calc.clickPrevPercentX = calc.clickPrevPercentY = NaN;
| |
| calc.clickStartX = calc.clickStartY = calc.clickStartPercentX = calc.clickStartPercentY = NaN;
| |
| calc.clickInProgress = 0;
| |
| calc.clickType = NaN;
| |
| updateClick(calc);
| |
| }
| |
| }
| |
| unsetBusy();
| |
| // repeatInfo will be null if until formula evaluates to true on first iteration
| |
| if ( calc.repeatInfo && maxIterations > 1 && repeat >= 500 ) {
| |
| calc.repeatInfo.cancelIdentifier = window.setInterval( doStuff, repeat );
| |
| }
| |
| };
| |
| };
| |
| | |
| var connectButtonToFormula = function ( calc, $button, forAttr, formulaAttr, repeatDuration, maxIterations, toggle, untilAttr, draggable ) {
| |
| // The intended use case for repeats is something like a chess board that automatically plays the next move.
| |
| // We don't want it to be used for animation or otherwise harm performance, so we limit to once per 500ms and
| |
| // only allow one repeat per container to be active at a time.
| |
| if ( repeatDuration !== undefined ) {
| |
| repeatDuration = Math.max( 500, Math.ceil(parseFloat( repeatDuration )*1000 ) );
| |
| if ( isNaN( repeatDuration ) ) {
| |
| repeatDuration = undefined;
| |
| }
| |
| }
| |
| if ( toggle === 'false' ) {
| |
| toggle = false;
| |
| }
| |
| // If neither repeatDuration or maxIterations is set then the button is normal and does not repeat but does not cancel
| |
| // if repeatDuration is set and no maxIterations, then maxIterations is infinite
| |
| // If maxIterations is 0, then this cancels the current repeat without doing anything
| |
| // If maxIterations is 1, then cancel but do normal button op (but don't repeat)
| |
| if ( maxIterations !== undefined ) {
| |
| maxIterations = parseInt( maxIterations );
| |
| } else if( repeatDuration ) {
| |
| maxIterations = Infinity;
| |
| }
| |
| if ( typeof forAttr !== 'string' || typeof formulaAttr !== 'string' ) {
| |
| // Nothing to do. Skip.
| |
| return;
| |
| }
| |
| if ( formulaAttr.length > 2000 || (untilAttr && untilAttr.length > 2000 ) ) {
| |
| console.log( "Skipping button with too long formula" );
| |
| return;
| |
| }
| |
| var until = null;
| |
| try {
| |
| until = (new Parser( untilAttr )).parse();
| |
| } catch( e ) {
| |
| console.log( "Calculator: Error parsing until formula of button " + $button[0].id + ": " + e.message );
| |
| }
| |
| var forList = forAttr.split( ';' );
| |
| var formulaList = formulaAttr.split( ';' );
| |
| var forElms = [];
| |
| var formulaASTs = [];
| |
| for ( var i = 0; i < forList.length; i++ ) {
| |
| if (formulaList[i] === undefined) {
| |
| break;
| |
| }
| |
| forElms[i] = calc.elmList[forList[i]];
| |
| try {
| |
| formulaASTs[i] = (new Parser( formulaList[i] )).parse();
| |
| } catch( e ) {
| |
| formulaASTs[i] = null;
| |
| console.log( "Calculator: Error parsing formula of button " + $button[0].id + " for " + forList[i] + ": " + e.message );
| |
| }
| |
| }
| |
| if ( draggable ) {
| |
| $button[0].addEventListener( 'pointerdown', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
| |
| $button[0].addEventListener( 'pointerup', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
| |
| $button[0].addEventListener( 'pointerleave', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
| |
| $button[0].addEventListener( 'pointermove', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
| |
| } else {
| |
| $button[0].addEventListener( 'click', makeButtonCallback( calc, forElms, formulaASTs, repeatDuration, maxIterations, toggle, until ) );
| |
| }
| |
| };
| |
| | |
| CalculatorWidgets.prototype.connectButtonDisable = function ( button, formula ) {
| |
| if ( formula === '' || formula === '0' || formula === 'false' ) {
| |
| return;
| |
| }
| |
| if ( formula.length > 2000 ) {
| |
| console.log( "Calculator: Disable formula for button " + button.id + " is too long" );
| |
| return;
| |
| }
| |
| var formulaAST;
| |
| try {
| |
| formulaAST = (new Parser( formula )).parse();
| |
| } catch( e ) {
| |
| console.log( "Error parsing disable formula of button " + button.id + ": " + e.message );
| |
| return;
| |
| }
| |
| this.buttonInfo[button.id] = {
| |
| elm: button,
| |
| formula: formulaAST,
| |
| };
| |
| // Add disabled formula's dependencies to the dependency tree.
| |
| var idents = getIdentifiers( formulaAST );
| |
| var that = this;
| |
| var item = '--button-disable-' + button.id;
| |
| idents.forEach( function (ident) {
| |
| if ( !that.backlinks[ident] ) {
| |
| that.backlinks[ident] = [];
| |
| }
| |
| that.backlinks[ident].push( item );
| |
| } );
| |
| | |
| // Now set the initial value.
| |
| var curValue = this.evaluate( formulaAST );
| |
| if ( !isNaN( curValue ) && !almostEquals( curValue, 0 ) ) {
| |
| button.disabled = true;
| |
| }
| |
| };
| |
| | |
| CalculatorWidgets.prototype.setupButtons = function() {
| |
| var calc = this;
| |
| $( this.parent ).find( '.calculator-field-button' ).replaceWith( function () {
| |
| var b = $( '<button type="button">' );
| |
| if ( this.id && this.id !== '__proto__' ) {
| |
| b.attr( 'id', this.id );
| |
| } else {
| |
| b.attr( 'id', 'calculator-button-' + Math.ceil(Math.random()*100000000000) );
| |
| }
| |
| if ( this.title ) {
| |
| b.attr( 'title', this.title );
| |
| }
| |
| if ( this.style.cssText ) {
| |
| b.attr( 'style', this.style.cssText );
| |
| }
| |
| if ( this.classList.contains( 'cdx-button' ) ) {
| |
| mw.loader.load( '@wikimedia/codex' );
| |
| }
| |
| if ( this.className !== 'calculator-field-button' ) {
| |
| var extraClass = this.dataset.calculatorClass === undefined ? '' : ' ' + this.dataset.calculatorClass;
| |
| b.attr( 'class', this.className.replace( /(^| )calculator-field-button( |$)/g, ' ' ) + extraClass );
| |
| }
| |
| if ( this.dataset.calculatorAlt ) {
| |
| b.attr( 'aria-label', this.dataset.calculatorAlt );
| |
| }
| |
| if ( this.dataset.calculatorAriaLive ) {
| |
| b.attr( 'aria-live', this.dataset.calculatorAriaLive );
| |
| }
| |
| if ( this.getAttribute( 'role' ) ) {
| |
| b.attr( 'role', this.getAttribute( 'role' ) );
| |
| }
| |
| if ( this.getAttribute( 'aria-owns' ) ) {
| |
| b.attr( 'aria-owns', this.getAttribute( 'aria-owns' ) );
| |
| }
| |
| if ( this.getAttribute( 'aria-flowto' ) ) {
| |
| b.attr( 'aria-flowto', this.getAttribute( 'aria-flowto' ) );
| |
| }
| |
| if ( this.dataset.calculatorDisabled !== undefined ) {
| |
| // TODO: Would be cool if this was a formula.
| |
| if ( this.dataset.calculatorDisabled === 'true' || this.dataset.calculatorDisabled === '1' ) {
| |
| // Simple case
| |
| b.attr( 'disabled', true );
| |
| } else {
| |
| calc.connectButtonDisable( b[0], this.dataset.calculatorDisabled );
| |
| }
| |
| }
| |
| // This will be a no-op if no formula or for specified.
| |
| connectButtonToFormula(
| |
| calc,
| |
| b,
| |
| this.dataset.calculatorFor,
| |
| this.dataset.calculatorFormula,
| |
| this.dataset.calculatorDelay,
| |
| this.dataset.calculatorMaxIterations,
| |
| this.dataset.calculatorToggle,
| |
| this.dataset.calculatorUntil
| |
| );
| |
| | |
| b.append( this.childNodes );
| |
| return b;
| |
| } );
| |
| // This is meant to allow image map type things.
| |
| $( this.parent ).find( '.calculator-field-buttonraw' ).each( function () {
| |
| if (!this.id || this.id === '__proto__') {
| |
| this.id = 'calculator-button-' + Math.ceil(Math.random()*100000000000);
| |
| }
| |
| // Make sure we do not process twice.
| |
| this.classList.remove( 'calculator-field-buttonraw' );
| |
| connectButtonToFormula(
| |
| calc,
| |
| $(this),
| |
| this.dataset.calculatorFor,
| |
| this.dataset.calculatorFormula,
| |
| this.dataset.calculatorDelay,
| |
| this.dataset.calculatorMaxIterations,
| |
| this.dataset.calculatorToggle,
| |
| this.dataset.calculatorUntil,
| |
| this.classList.contains( 'calculator-field-buttondraggable' )
| |
| );
| |
| } );
| |
| };
| |
| | |
| CalculatorWidgets.prototype.changeHandler = function(e) {
| |
| this.inProgressRefresh = Object.create(null);
| |
| var unsetBusy = this.setBusy();
| |
| doStats();
| |
| e.target.removeAttribute( 'data-calculator-real-value' );
| |
| var itemId = e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
| |
| var itemsToRefresh = [ this.backlinks[itemId] ];
| |
| this.inProgressRefresh[itemId] = true;
| |
| this.setValueProperties( e.target, getValueOfElm( e.target, this.mappingInput[e.target.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )] ) );
| |
| | |
| var staritem = itemId.replace( /[0-9]+$/, '*' );
| |
| if ( itemId.match( /[0-9]+$/ ) && this.backlinks[staritem] ) {
| |
| this.inProgressRefresh[staritem] = true;
| |
| itemsToRefresh.push( this.backlinks[staritem] );
| |
| }
| |
| | |
| this.refresh( itemsToRefresh.flat() );
| |
| this.inProgressRefresh = undefined;
| |
| unsetBusy();
| |
| };
| |
| | |
| // The subset of formatting that still returns a float-like thing.
| |
| // input type=number generally expects something that looks like a float (However if given "1.200" it will preserve the 0's)
| |
| var formatNumeric = function( n, options ) {
| |
| if ( typeof n !== "number" ) {
| |
| return n;
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorDecimals ) ) ) {
| |
| return n.toFixed( parseInt( options.calculatorDecimals ) );
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorPrecision ) ) ) {
| |
| return n.toPrecision( parseInt( options.calculatorPrecision ) );
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorExponentialPrecision ) ) ) {
| |
| return n.toExponential( parseInt( options.calculatorExponentialPrecision ) );
| |
| }
| |
| return n;
| |
| };
| |
| var format = function ( n, options, mapping ) {
| |
| var res = n.toString();
| |
| if ( typeof n !== "number" ) {
| |
| return res;
| |
| }
| |
| if ( mapping && mapping[n] ) {
| |
| return mapping[n];
| |
| }
| |
| if ( mapping && mapping['default'] !== undefined ) {
| |
| return mapping['default'];
| |
| }
| |
| if ( isNaN( n ) ) {
| |
| return options.calculatorNanText ? options.calculatorNanText : '?';
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorDecimals ) ) ) {
| |
| res = n.toFixed( parseInt( options.calculatorDecimals ) );
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorPrecision ) ) ) {
| |
| res = n.toPrecision( parseInt( options.calculatorPrecision ) );
| |
| }
| |
| if ( !isNaN( parseInt( options.calculatorExponentialPrecision ) ) ) {
| |
| res = n.toExponential( parseInt( options.calculatorExponentialPrecision ) );
| |
| }
| |
| | |
| res = res.replace( /e([+-])([0-9]+)$/, function (m, sign, exp) {
| |
| var tmp = "×10";
| |
| if ( sign === '-' ) {
| |
| tmp += '⁻';
| |
| }
| |
| tmp += exp.replace( /[0-9]/g, function (m) {
| |
| return [ '⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹' ][m];
| |
| } );
| |
| return tmp;
| |
| } );
| |
| return res;
| |
| };
| |
| | |
| CalculatorWidgets.prototype.setValuePropertiesSpecialPurpose = function( id ) {
| |
| // Maybe in future we should set CSS variables for each special purpose item. Not sure if that would be useful.
| |
| if ( id.match( /^--button-disable-.+/ ) ) {
| |
| var buttonId = id.substring(17);
| |
| if ( this.buttonInfo[buttonId] ) {
| |
| var res = this.evaluate( this.buttonInfo[buttonId].formula );
| |
| this.buttonInfo[buttonId].elm.disabled = !isNaN( res ) && !almostEquals( res, 0 );
| |
| } else {
| |
| console.log( 'Tried to refresh disable status for ' + buttonId + ' but there is no such button.' );
| |
| }
| |
| }
| |
| return;
| |
| };
| |
| | |
| // Set a data attribute, classes, etc. For ease of targeting via CSS.
| |
| CalculatorWidgets.prototype.setValueProperties = function ( elm, value ) {
| |
| var itemId = elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' );
| |
| elm.dataset.calculatorFieldValue = value.toFixed(5);
| |
| if ( !itemId.match( /^auto\d\d\d\d+unnamed$/ ) ) {
| |
| this.parent.style.setProperty( '--calculator-' + itemId, value );
| |
| }
| |
| if ( !isNaN( value ) && !almostEquals( value, 0 ) ) {
| |
| elm.classList.remove( 'calculator-value-false' );
| |
| elm.classList.add( 'calculator-value-true' );
| |
| } else {
| |
| elm.classList.remove( 'calculator-value-true' );
| |
| elm.classList.add( 'calculator-value-false' );
| |
| }
| |
| };
| |
| | |
| CalculatorWidgets.prototype.refresh = function (itemIds) {
| |
| var i;
| |
| // Based on https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
| |
| var permMarks = Object.create(null);
| |
| var tempMarks = Object.create(null);
| |
| var topList = [];
| |
| var that = this;
| |
| if ( !itemIds ) {
| |
| return;
| |
| }
| |
| var visit = function( item ) {
| |
| var i;
| |
| if ( permMarks[item] ) {
| |
| return;
| |
| }
| |
| if ( tempMarks[item] ) {
| |
| if ( !item.match( /^--radio-/ ) ) {
| |
| // Loops are expected for radio buttons.
| |
| console.log( "Loop detected in calculator. '" + item + "' may not be updated properly. (This is expected for radio buttons)" );
| |
| }
| |
| return;
| |
| }
| |
| tempMarks[item] = true;
| |
| for ( i = 0; that.backlinks[item] && i < that.backlinks[item].length; i++ ) {
| |
| visit(that.backlinks[item][i]);
| |
| }
| |
| // Special case for index()
| |
| if ( item.match( /[0-9]+$/ ) ) {
| |
| var staritem = item.replace( /[0-9]+$/, '*' );
| |
| for ( i = 0; that.backlinks[staritem] && i < that.backlinks[staritem].length; i++ ) {
| |
| visit(that.backlinks[staritem][i]);
| |
| }
| |
| }
| |
| permMarks[item] = true;
| |
| topList.push(item); // later we iterate backwards through this list.
| |
| };
| |
| for ( i = 0; i < itemIds.length; i++ ) {
| |
| if ( itemIds[i] !== undefined ) {
| |
| visit( itemIds[i] );
| |
| }
| |
| }
| |
| | |
| for ( i = topList.length - 1; i >= 0; i-- ) {
| |
| var itemId = topList[i];
| |
| if ( this.inProgressRefresh[itemId] ) {
| |
| // It is expected radio buttons loop, since they are depend on each other. If you check one, the others uncheck.
| |
| if ( this.elmList[itemId].type !== 'radio' ) {
| |
| console.log( "Loop Detected! Skipping " + itemId );
| |
| }
| |
| continue;
| |
| }
| |
| if ( itemId.match( /^--/ ) ) {
| |
| // A special purpose identifier
| |
| this.setValuePropertiesSpecialPurpose( itemId );
| |
| this.inProgressRefresh[itemId] = true;
| |
| continue;
| |
| } else if ( !this.itemList[itemId] || this.itemList[itemId] instanceof Null ) {
| |
| // This mostly should not happen but might if refresh on page load is set or with radio buttons.
| |
| var elm = this.elmList[itemId];
| |
| this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) );
| |
| if ( this.elmList[itemId].type !== 'radio' ) {
| |
| console.log( "Tried to refresh field " + itemId + " with no formula" );
| |
| }
| |
| continue;
| |
| }
| |
| this.inProgressRefresh[itemId] = true;
| |
| var elm = this.elmList[itemId];
| |
| // Special handling for radio buttons to prevent loops from indirect clicks.
| |
| // If someone clicked a different radio button, we want to refresh dependencies of the unclicked button that is now deselected
| |
| // but we don't want to re-evaluate its formula which might reselect it.
| |
| if ( elm.type === 'radio' && this.inProgressRefresh['--radio-' + elm.name.replace( /^calcgadget-\d+-/, '' )] ) {
| |
| this.setValueProperties( elm, getValueOfElm( elm, this.mappingInput[itemId] ) );
| |
| continue;
| |
| }
| |
| | |
| var res = this.evaluate( this.itemList[itemId] );
| |
| this.setValue( elm, res );
| |
| }
| |
| };
| |
| | |
| CalculatorWidgets.prototype.setValue = function( elm, res ) {
| |
| this.setValueProperties( elm, res );
| |
| var mappingOut = this.mappingOutput[elm.id.replace( /^(?:calcdisambig-\d+-)?calculator-field-/, '' )]
| |
| if ( elm.tagName === 'INPUT' ) {
| |
| if ( elm.type === 'range' ) {
| |
| elm.value = res;
| |
| } else if ( elm.type === 'number' ) {
| |
| // We support some formatting types but not others
| |
| // as browser still expects this to be a number.
| |
| elm.setAttribute( 'data-calculator-real-value', res );
| |
| elm.value = formatNumeric( res, elm.dataset );
| |
| } else if ( elm.type === 'radio' || elm.type === 'checkbox' ) {
| |
| elm.checked = !isNaN( res ) && !almostEquals( res, 0 );
| |
| if ( !elm.checked && elm.dataset.calculatorValue ) {
| |
| // If we are unchecking this box, set its value back to the default
| |
| // so that if a human rechecks it, the value of the control is the default
| |
| // value and not the last value of the formula.
| |
| elm.value = elm.dataset.calculatorValue;
| |
| }
| |
| } else {
| |
| elm.setAttribute( 'data-calculator-real-value', res );
| |
| elm.value = format( res, elm.dataset, mappingOut );
| |
| }
| |
| } else if ( elm.tagName === 'SELECT' ) {
| |
| elm.value = res;
| |
| } else if ( elm.dataset.calculatorType !== 'passthru' ) {
| |
| // plain type.
| |
| elm.setAttribute( 'data-calculator-real-value', res );
| |
| elm.textContent = format( res, elm.dataset, mappingOut );
| |
| }
| |
| }
| |
| | |
| // Set a calculator container to busy, which pauses some screen readers until everything is updated.
| |
| // Returns a callback to unset to busy.
| |
| CalculatorWidgets.prototype.setBusy = function () {
| |
| var curVal = this.parent.getAttribute( 'aria-busy' );
| |
| if ( curVal === 'true' ) {
| |
| return function () {};
| |
| }
| |
| this.parent.setAttribute( 'aria-busy', 'true' );
| |
| var that = this;
| |
| return function () {
| |
| that.parent.setAttribute( 'aria-busy', 'false' );
| |
| };
| |
| }
| |
| | |
| CalculatorWidgets.prototype.evaluate = evaluate;
| |
| | |
| mw.hook( 'wikipage.content' ).add( setup );
| |
| } )();
| |