/** * Binds a function to a this object for use by callbacks * @param instance {!Object} The this object to bind to. * @return A closure that will call the original function bound to the given * instance parameter. */ Function.prototype.bind = function(instance) { var self = this; return function() { self.apply(instance, arguments); }; }; /**** ** Start of temporary evaling logic * * To support expression evaluations in the short term, the strategry is to just * use 'eval' with sanitized input. Long term, I'm working on using Antlr and * a custom grammar to generate an appropriate lexer/parser to deal with the * expressions. * * For now, sanitation consists of making sure only whitelisted characters are * in the input (to restrict what you can do), and that all identifiers get a * namespace prefix added to them so that they cannot access the global this. * So "2 + myvar" turns into "2 + symbols.myvar" for evaling. * * To point out a more subtle aspect of this validation, periods are in the * whitelist because of their use in decimals, however there is a special regex * check to make sure that all uses of periods are just for decimals and not * attribute access. */ /** * A regex that validates that only whitelisted characters are in the input. * @type {RegExp} */ var textWhitelistValidator = new RegExp("[-a-zA-Z0-9.+*/()= ,]+"); /** * Validates that the input text only contains whitelisted characters. * @param text {string} The text to validate. * @return {boolean} whether the input text is only whitelisted characters or * not. */ function isTextClean(text) { data = textWhitelistValidator.exec(text); return data && data[0] && data[0].length > 0 && data[0].length == text.length; } /** * A regex that finds all uses of periods in decimals. * @type {RegExp} */ var validPeriodUse = /\.(?:[0-9]|$)/g; /** * Validates that all uses of periods within the input text are for decimals * and not for attribute access. * @param text {string} The intput text to validate. * @return {boolean} Whether all period use is valid or not. */ function isPeriodUseValid(text) { // Remove all valid uses of dot, and if there are any dots left over we know // they are 'evil' property accesses return text.replace(validPeriodUse, '').indexOf('.') == -1; } /** * The symbol table namespace all variables and functions are forced to be a * part of. * @type {!Object} */ var symbols = { }; /** * A regex that finds all identifiers within the input. * @type {RegExp} */ var symbolRegex = /([a-zA-Z][a-zA-Z0-9]*)/g; /** * Adds the 'symbol' namespace to all symbols within the input and returns * the modified input. * @param text {string} The input to namespace. * @return {string} The input transformed so that all symbols are referenced * through the 'symbol' namespace. */ function addNamespaceToSymbols(text) { return text.replace(symbolRegex, 'symbols.$1'); } /** * A regex that finds all leading and trailing whitespace on a string. * @type {RegExp} */ var trimWhitespaceRegex = /^\s+|\s+$/g; /** * Evaluates a string input expression and returns the result. * @param text {string} The text to evaluate. * @return {*} the result of evaluating the expression, or the string '...' * if there was any issue evaluating the input. */ function evalExpression(text) { var result = ''; if (text) { text = text.replace(trimWhitespaceRegex, ''); if(text != '') { if (!isTextClean(text) || !isPeriodUseValid(text)) { result = 'invalid'; } else { try { result = eval(addNamespaceToSymbols(text)); if (result === undefined) { //symbol that's never been assigned result = ''; } else if (isNaN(result)) { result = '...'; } } catch (e) { result = '...'; } } } } return result; } /** * Registers a javascript function for use within calculator expressions. * @param name {string} The name calcualtor expressions will use to call this * function. * @param func {Function} The function that will be called when the name is * used in a calcualtor expression. */ function registerFunction(name, func) { symbols[name] = func; func.toString = function() { return Number.NaN; }; } /** * Updates the value of a variable in the symbol table. * @param name {string} The name of the variable to update. * @param value {*} The value to set for the variable. */ function updateVar(name, value) { symbols[name] = value; } /**** ** End of temporary evaling logic */ registerFunction('abs', function(number) { return Math.abs(number); }); registerFunction('acos', function(number) { return Math.acos(number); }); registerFunction('asin', function(number) { return Math.asin(number); }); registerFunction('atan', function(number) { return Math.atan(number); }); registerFunction('atan2', function(n1, n2) { return Math.atan2(n1, n2); }); registerFunction('ceil', function(number) { return Math.ceil(number); }); registerFunction('cos', function(number) { return Math.cos(number); }); registerFunction('floor', function(number) { return Math.floor(number); }); registerFunction('log', function(number) { return Math.log(number); }); registerFunction('max', function(n1, n2) { return Math.max(n1, n2); }); registerFunction('min', function(n1, n2) { return Math.min(n1, n2); }); registerFunction('pow', function(n1, n2) { return Math.pow(n1, n2); }); registerFunction('random', function() { return Math.random(); }); registerFunction('round', function(number) { return Math.round(number); }); registerFunction('sin', function(number) { return Math.sin(number); }); registerFunction('sqrt', function(number) { return Math.sqrt(number); }); registerFunction('tan', function(number) { return Math.tan(number); }); /** * Creates an expression object that manages an expression cell within the * display area. * @constructor */ function Expression() { this.editDiv_ = document.createElement('div'); this.editDiv_.className = 'expression_editor'; this.editDiv_.contentEditable = true; } /** * Returns the HTML element that acts as the expression editor. * @return {!Element} The editor element. */ Expression.prototype.getEditor = function() { return this.editDiv_; }; /** * Returns the current expression as a string. * @return {string} The current expression. */ Expression.prototype.getText = function() { var children = this.editDiv_.childNodes; if (children.length == 1) { return children[0].nodeValue; } else { var contents = [] for (var x = 0; x < children.length; ++x) { contents.push(children[x].nodeValue); } return contents.join(''); } }; /** * Creates a result object that manages a result cell within the display area. * @constructor. */ function Result() { this.resultSpan_ = document.createElement('span'); this.resultSpan_.className = 'result_display'; this.resultSpan_.appendChild(document.createTextNode('')); } /** * Returns the HTML element that acts as the result display. * @return {!Element} The display element. */ Result.prototype.getDisplay = function() { return this.resultSpan_; }; /** * Returns the currently displayed result text. * @return {string} The result text. */ Result.prototype.getText = function() { return this.resultSpan_.childNodes[0].nodeValue; }; /** * Sets the currently displayed result text. * @param text {string} The new text to display. */ Result.prototype.setText = function(text) { return this.resultSpan_.childNodes[0].nodeValue = text; }; /** * Creates a line in the display, which is composed of an expression and a * result. * @param lineNumber {number} The line number of this line. * @param expressionChangedCallback {function(DisplayLine)} A callback to invoke * when the expression has been changed by the user. This display line will * be passed as the only input. * @constructor */ function DisplayLine(lineNumber, expressionChangedCallback) { this.lineNumber_ = lineNumber; this.expression_ = new Expression(); this.result_ = new Result(); this.row_ = this.setupLayout_(); this.setupExpressionHandling_(expressionChangedCallback); } /** * Returns the line number of this display line. * @return {number} the line number. */ DisplayLine.prototype.getLineNumber = function() { return this.lineNumber_; }; /** * Returns the expression of this display line. * @return {Expression} The expression. */ DisplayLine.prototype.getExpression = function() { return this.expression_; }; /** * Returns the result of this display line. * @return {Result} The result. */ DisplayLine.prototype.getResult = function() { return this.result_; }; /** * Returns the table row this display line manages. * @return {Element} the table row element. */ DisplayLine.prototype.getRow = function() { return this.row_; }; /** * Sets up detection of the user editing the expression. * @param changeCallback {function(DisplayLine)} The callback to call when * the user edits the expression. * @private */ DisplayLine.prototype.setupExpressionHandling_ = function(changeCallback) { var self = this; function callback() { changeCallback(self); } this.expression_.getEditor().addEventListener('keyup', callback, true); this.expression_.getEditor().addEventListener('paste', callback, true); }; /** * Creates the table row needed by this display line instance. * @return {!Element} the new table row this display line will use. */ DisplayLine.prototype.setupLayout_ = function() { var row = document.createElement('tr'); row.className = 'expression_row'; var lineNumberCell = document.createElement('td'); lineNumberCell.className = 'line_number'; lineNumberCell.appendChild(document.createTextNode(this.lineNumber_)); row.appendChild(lineNumberCell); var editor = this.expression_.getEditor(); var expressionCell = document.createElement('td'); expressionCell.className = 'expression_cell'; expressionCell.appendChild(editor) row.appendChild(expressionCell); var resultCell = document.createElement('td'); resultCell.className = 'result_cell'; resultCell.appendChild(this.result_.getDisplay()); resultCell.addEventListener('click', function() { editor.focus(); }, true); row.appendChild(resultCell); return row; }; /** * Forces the browser to put cursor focus on this display line. */ DisplayLine.prototype.focus = function() { this.expression_.getEditor().focus(); }; /** * Creates a new calcualtor that manages the calculator page. Only one of these * should be created per page. * @constructor. */ function Calculator() { this.lines_ = []; this.displayInScrollingMode_ = false; this.activeLine_ = null; } /** * Initializes the calcualtor once the page is finished loading. */ Calculator.prototype.init = function() { window.addEventListener('resize', this.determineDisplayLayout.bind(this), true); this.initializeKeypad_(); this.initializeNextLine_(); }; /** * Configures a keypad button to insert the given text into the active line. * @param buttonId {string} The DOM ID of the keypad button to configure. * @param text {string} The text to insert into the active line when the keypad * button is pressed. * @private */ Calculator.prototype.hookInputButton_ = function(buttonId, text) { var self = this; document.getElementById(buttonId).addEventListener('click', function() { if (self.activeLine_) { document.execCommand('inserthtml', false, text); self.handleExpressionChanged_(self.activeLine_); } }, true); } /** * Initializes the keypad to have working buttons that insert text into the * current line. * @private */ Calculator.prototype.initializeKeypad_ = function() { for (var x = 0; x < 10; ++x) { this.hookInputButton_('kp_' + x, '' + x); } this.hookInputButton_('kp_dot', '.'); this.hookInputButton_('kp_div', '/'); this.hookInputButton_('kp_mul', '*'); this.hookInputButton_('kp_sub', '-'); this.hookInputButton_('kp_add', '+'); document.getElementById('kp_eq').addEventListener( 'click', this.initializeNextLine_.bind(this), false); }; /** * Creates a new display line and initializes it as the active line. * @private */ Calculator.prototype.initializeNextLine_ = function() { var nextLineNumber = this.lines_.length + 1; var line = new DisplayLine(nextLineNumber, this.handleExpressionChanged_.bind(this)); this.lines_.push(line); this.hookLine_(line); document.getElementById('display_grid_body').appendChild(line.getRow()); this.determineDisplayLayout(); line.focus(); }; /** * Handles live-updating of relevant display lines when the user is updating * an expression. * @param changedLine {number} the line number of the display line that is * being changed. * @private */ Calculator.prototype.handleExpressionChanged_ = function(changedLine) { for (var x = changedLine.getLineNumber() - 1; x < this.lines_.length; ++x) { var line = this.lines_[x]; var val = evalExpression(line.getExpression().getText()); updateVar('line' + line.getLineNumber(), val); line.getResult().setText(val); } }; /** * Hooks various user events on a display line that need to be handled at a * higher level. Currently this handles making new lines when hitting enter, * and tracking browser focus for determining the currently active line. */ Calculator.prototype.hookLine_ = function(line) { var expression = line.getExpression(); var self = this; expression.getEditor().addEventListener('keydown', function(event) { if (event.which == 13) { event.preventDefault(); event.stopPropagation(); self.initializeNextLine_(); } }, true); expression.getEditor().addEventListener('focus', function(event) { self.activeLine_ = line; }, true); }; /** * Called when the browser is resized to determine how to flow the display area. * * There are two flow modes: If the display is larger than the room the lines * need, then the lines should be aligned to the bottom of the display. If the * display is smaller than the room the lines need, then enable a scrolling * behavior. */ Calculator.prototype.determineDisplayLayout = function() { if (this.displayInScrollingMode_) { // Determine if we have to take the display out of scrolling mode so that // the text will align to the bottom var displayScroll = document.getElementById('display_scroll'); if (displayScroll.clientHeight == displayScroll.scrollHeight) { // Revert the explicit height so that it shrinks to be only as high as // needed, which will let the containing cell align it to the bottom. displayScroll.style.height = 'auto'; this.displayInScrollingMode_ = false; } } else { // Determine if we have to put the display in scrolling mode because the // content is too large for the cell size var displayCell = document.getElementById('display_cell'); var displayScroll = document.getElementById('display_scroll'); if (displayScroll.clientHeight == displayCell.clientHeight) { // Assign an explicit height so that the overflow css property kicks in. displayScroll.style.height = '100%'; this.displayInScrollingMode_ = true; } } };