537 lines
16 KiB
JavaScript
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;
|
||
|
}
|
||
|
}
|
||
|
};
|