Accueil/js/calculator.js

537 lines
16 KiB
JavaScript

/**
* 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;
}
}
};