미디어위키:Gadget-calculator.js
참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다. 구글 크롬, 파이어폭스, 마이크로소프트 엣지, 사파리: ⇧ Shift 키를 누른 채 "새로 고침" 버튼을 클릭하십시오. 더 자세한 정보를 보려면 위키백과:캐시 무시하기 항목을 참고하십시오.
/* On-Wiki calculator script. See [[Template:Calculator]]. Created by [[User:Bawolff]]. From https://mdwiki.org/wiki/MediaWiki:Gadget-calculator.js (released under Creative Commons Attribution-ShareAlike 3.0 and 4.0 International Public License). See original source for attribution history * * 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. * * 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', 'ceil', 'cos', 'cosh', 'exp', 'floor', 'hypot', 'log', 'log10', 'log2', 'max', 'min', 'pow', 'random', 'sign', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc' ]; var otherFuncs = [ 'ifzero', 'coalesce', 'iffinite', 'ifnan', 'ifpositive', 'ifequal', 'round', 'jsround', 'not', 'and', 'or', 'bool', 'ifless', 'iflessorequal', 'ifgreater', 'ifgreaterorequal', 'ifbetween', 'xor', 'index', 'switch' ]; var allFuncs = mathFuncs.concat(otherFuncs); var convertFloat = function ( f ) { f = f.replace( /×\s?10/, 'e' ); return 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, '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 prenthesis." ); } return res; }, expression: function () { var text2, res, res2; res = this.term(); this.consume( 'WS' ); while ( this.check( "PLUSMINUS" )) { var op = this.consume( "PLUSMINUS" ); res2 = this.term(); 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 = '/'; res2 = this.term(); res = new Operator( op, [ res, res2 ] ); } return res; }, factor: function () { if ( this.check( 'pi' ) ) { this.consume( 'pi' ); return new Numb( Math.PI ) } 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( '(' ); res = this.expression(); this.consume( ')' ); return 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; } var getValueOfElm = function (elm) { if ( elm.tagName === 'INPUT' ) { if ( elm.type === 'radio' || elm.type === 'checkbox' ) { return elm.checked ? 1 : 0; } return parseFloat( convertFloat( elm.value ) ); } else { return parseFloat( convertFloat( elm.textContent ) ); } } // Evaluate the value of an AST at runtime. var evaluate = function( ast, elmList ) { var i, then, ielse; if ( ast instanceof Numb ) { return ast.value; } if ( ast instanceof Ident ) { var 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 ) } 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(evaluate( ast.args[1], elmList )); if ( !Number.isSafeInteger( indexValue ) || indexValue < 0 ) { return NaN; } var res = elmList[ast.args[0].value + indexValue]; if ( res === undefined ) { return ast.args.length >= 3 ? evaluate( ast.args[2] ) : NaN; } return getValueOfElm( res ); } evaledArgs = ast.args.map( function (i) { return evaluate( i, elmList ) } ); 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; } 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; } // 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 + '*') } ); } 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) { var backlinks = {}; 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 ); } ); } 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( /^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 () { // FIXME This probably needs to be updated to use Prometheus. if ( window.calculatorStatsAlreadyDone !== true ) { window.calculatorStatsAlreadyDone = true; mw.track( 'counter.gadget_calculator._all' ); mw.track( 'counter.gadget_calculator.' + mw.config.get( 'wgDBname' ) + '_all' ); var statName = mw.config.get( 'wgDBname' ) + '_' + mw.config.get( 'wgPageName' ); statName = encodeURIComponent( statName ); // Symbols don't seem to work. statName = statName.replace( /[^a-zA-Z0-9_]/g, '_' ); mw.track( 'counter.gadget_calculator.' + statName ); } } var CalculatorWidgets = function( elms, parent ) { this.parent = parent; this.rand = Math.floor(Math.random()*1000000000) this.itemList = {}; this.elmList = {}; this.backlinks = {}; this.inProgressRefresh = undefined; var that = this; 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; if ( !id ) { id = 'calculator-field-auto' + Math.floor( Math.random()*10000000 ) + 'unnamed'; } var defaultVal = ("" + elm.textContent).trim(); if ( type === undefined || !id.match( /^calculator-field-[a-zA-Z_][a-zA-Z0-9_]*$/ ) ) { console.log( "Skipping " + id ); continue; } try { var 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 = 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 = parseFloat(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; // 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 ( 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 ( type === 'number' || type === 'text' || type === 'radio' || type === 'checkbox' || type === "hidden" || type === "range" ) { input.type = type; } if ( type === 'radio' || type === 'checkbox' ) { input.onchange = this.changeHandler.bind(this); // some browsers dont fire oninput for checkboxrs/radio if ( !isNaN( defaultVal ) && !almostEquals( defaultVal, 0 ) ) { input.checked = true; } } input.id = id; elm.replaceWith( input ); elm = input; input.oninput = 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 ( formula && type !== 'passthru' ) { // Tell screen reader to announce changes right away. // unclear if this is too assertive. It is also unclear if we should also do this for <input>'s. elm.setAttribute( 'role', 'alert' ); } if ( elm.id === '' ) { elm.id = id; } } var itemId = id.replace( /^calculator-field-/, '' ); this.itemList[itemId] = formulaAST; this.elmList[itemId] = elm; } this.backlinks = buildBacklinks( this.itemList ); if ( this.parent.dataset.calculatorRefreshOnLoad && this.parent.dataset.calculatorRefreshOnLoad === 'true' ) { this.inProgressRefresh = {}; this.refresh( Object.keys( this.itemList ).filter( function (a) { return !(that.itemList[a] instanceof Null); } ) ); this.inProgressRefresh = undefined; } this.setupButtons() } CalculatorWidgets.prototype.setupButtons = function() { var calc = this; $( this.parent ).find( '.calculator-field-button' ).replaceWith( function () { var b = $( '<button type="button">' ); if ( this.id ) { b.attr( 'id', this.id ); } 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.calculatorDisabled ) { // Would be cool if this was a formula. b.attr( 'disabled', true ); } var formula = this.dataset.calculatorFormula; if ( formula && formula.length > 2000 ) { console.log( "Skipping button with too long formula" ); formula = undefined; } if ( typeof this.dataset.calculatorFor === 'string' && typeof formula === 'string' ) { var forList = this.dataset.calculatorFor.split( ';' ); var formulaList = formula.split( ';' ); for ( var i = 0; i < forList.length; i++ ) { if (formulaList[i] === undefined) { break; } // function to make sure declared variables scoped just to loop. (function (i) { var forElm = calc.elmList[forList[i]]; var formulaAST; try { formulaAST = (new Parser( formulaList[i] )).parse(); } catch( e ) { console.log( "Error parsing formula of button " + b[0].id + ": " + e.message ); } if ( forElm && formulaAST ) { b[0].addEventListener( 'click', (function () { var res = evaluate( formulaAST, this.elmList ); this.setValue( forElm, res ); var e = new InputEvent( 'input' ); forElm.dispatchEvent( e ); }).bind(calc) ); } })(i); } } b.append( this.childNodes ); return b; } ); }; CalculatorWidgets.prototype.changeHandler = function(e) { this.inProgressRefresh = {}; doStats(); var itemId = e.target.id.replace( /^calculator-field-/, '' ); var itemsToRefresh = [ this.backlinks[itemId] ]; this.inProgressRefresh[itemId] = true; this.setValueProperties( e.target, getValueOfElm( e.target ) ); var staritem = itemId.replace( /[0-9]+$/, '*' ); if ( itemId.match( /[0-9]+$/ ) && this.backlinks[staritem] ) { this.inProgressRefresh[staritem] = true; itemsToRefresh.push( this.backlinks[staritem] ); } if ( e.target.type === 'radio' && e.target.name !== '' ) { // when a radio element gets checked, others get unchecked. We need to update the unchecked ones too. var otherElms = $( this.parent ).find( "[name=" + CSS.escape( e.target.name ) + ']' ); for ( var l = 0; l < otherElms.length; l++ ) { if ( otherElms[l].id === e.target.id || !otherElms[l].id.match( /^calculator-field-/ ) ) { continue; } var oElmId = otherElms[l].id.replace( /^calculator-field-/, '' ); this.setValueProperties( otherElms[l], getValueOfElm( otherElms[l] ) ); if ( this.backlinks[oElmId] ) { this.inProgressRefresh[oElmId] = true; itemsToRefresh.push( this.backlinks[oElmId] ); } } } this.refresh( itemsToRefresh.flat() ); this.inProgressRefresh = undefined; } var format = function ( n, options ) { var res = n.toString(); if ( typeof n !== "number" ) { return res; } if ( isNaN( n ) && options.calculatorNanText ) { return 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; }; // Set a data attribute, classes, etc. For ease of targeting via CSS. CalculatorWidgets.prototype.setValueProperties = function ( elm, value ) { var itemId = elm.id.replace( /^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] ) { console.log( "Loop detected in calculator. '" + item + "' may not be updated properly" ); 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] ) { console.log( "Loop Detected! Skipping " + itemId ); continue; } 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. this.setValueProperties( this.elmList[itemId], getValueOfElm( this.elmList[itemId] ) ); console.log( "Tried to refresh field " + itemId + " with no formula" ); continue; } this.inProgressRefresh[itemId] = true; var res = evaluate( this.itemList[itemId], this.elmList ); var elm = this.elmList[itemId]; this.setValue( elm, res ); } }; CalculatorWidgets.prototype.setValue = function( elm, res ) { this.setValueProperties( elm, res ); if ( elm.tagName === 'INPUT' ) { elm.value = elm.type === "number" || elm.type === "range" ? res : format( res, elm.dataset ); if ( elm.type === 'radio' || elm.type === 'checkbox' ) { elm.checked = !isNaN( res ) && !almostEquals( res, 0 ); } } else if ( elm.dataset.calculatorType !== 'passthru' ) { // plain type. elm.textContent = format( res, elm.dataset ); } } mw.hook( 'wikipage.content' ).add( setup ); } )();