Permalink
Find file
8ce0d48 Mar 17, 2014
@jaridmargolin @mickeyreiss @jarofghosts @bryanjswift
773 lines (667 sloc) 19 KB
/*!
* v0.0.9
* Copyright (c) 2013 First Opinion
* formatter.js is open sourced under the MIT license.
*
* thanks to digitalBush/jquery.maskedinput for some of the trickier
* keycode handling
*/
;(function (name, context, definition) {
if (typeof module !== 'undefined' && module.exports) { module.exports = definition(); }
else if (typeof define === 'function' && define.amd) { define(definition); }
else { context[name] = definition(); }
})('Formatter', this, function () {
// Defaults
var defaults = {
persistent: false,
repeat: false,
placeholder: ' '
};
// Regexs for input validation
var inptRegs = {
'9': /[0-9]/,
'a': /[A-Za-z]/,
'*': /[A-Za-z0-9]/
};
//
// Class Constructor - Called with new Formatter(el, opts)
// Responsible for setting up required instance variables, and
// attaching the event listener to the element.
//
function Formatter(el, opts) {
// Cache this
var self = this;
// Make sure we have an element. Make accesible to instance
self.el = el;
if (!self.el) {
throw new TypeError('Must provide an existing element');
}
// Merge opts with defaults
self.opts = utils.extend({}, defaults, opts);
// 1 pattern is special case
if (typeof self.opts.pattern !== 'undefined') {
self.opts.patterns = self._specFromSinglePattern(self.opts.pattern);
delete self.opts.pattern;
}
// Make sure we have valid opts
if (typeof self.opts.patterns === 'undefined') {
throw new TypeError('Must provide a pattern or array of patterns');
}
self.patternMatcher = patternMatcher(self.opts.patterns);
// Upate pattern with initial value
self._updatePattern();
// Init values
self.hldrs = {};
self.focus = 0;
// Add Listeners
utils.addListener(self.el, 'keydown', function (evt) {
self._keyDown(evt);
});
utils.addListener(self.el, 'keypress', function (evt) {
self._keyPress(evt);
});
utils.addListener(self.el, 'paste', function (evt) {
self._paste(evt);
});
// Persistence
if (self.opts.persistent) {
// Format on start
self._processKey('', false);
self.el.blur();
// Add Listeners
utils.addListener(self.el, 'focus', function (evt) {
self._focus(evt);
});
utils.addListener(self.el, 'click', function (evt) {
self._focus(evt);
});
utils.addListener(self.el, 'touchstart', function (evt) {
self._focus(evt);
});
}
}
//
// @public
// Add new char
//
Formatter.addInptType = function (chr, reg) {
inptRegs[chr] = reg;
};
//
// @public
// Apply the given pattern to the current input without moving caret.
//
Formatter.prototype.resetPattern = function (str) {
// Update opts to hold new pattern
this.opts.patterns = str ? this._specFromSinglePattern(str) : this.opts.patterns;
// Get current state
this.sel = inptSel.get(this.el);
this.val = this.el.value;
// Init values
this.delta = 0;
// Remove all formatted chars from val
this._removeChars();
this.patternMatcher = patternMatcher(this.opts.patterns);
// Update pattern
var newPattern = this.patternMatcher.getPattern(this.val);
this.mLength = newPattern.mLength;
this.chars = newPattern.chars;
this.inpts = newPattern.inpts;
// Format on start
this._processKey('', false, true);
};
//
// @private
// Determine correct format pattern based on input val
//
Formatter.prototype._updatePattern = function () {
// Determine appropriate pattern
var newPattern = this.patternMatcher.getPattern(this.val);
// Only update the pattern if there is an appropriate pattern for the value.
// Otherwise, leave the current pattern (and likely delete the latest character.)
if (newPattern) {
// Get info about the given pattern
this.mLength = newPattern.mLength;
this.chars = newPattern.chars;
this.inpts = newPattern.inpts;
}
};
//
// @private
// Handler called on all keyDown strokes. All keys trigger
// this handler. Only process delete keys.
//
Formatter.prototype._keyDown = function (evt) {
// The first thing we need is the character code
var k = evt.which || evt.keyCode;
// If delete key
if (k && utils.isDelKey(k)) {
// Process the keyCode and prevent default
this._processKey(null, k);
return utils.preventDefault(evt);
}
};
//
// @private
// Handler called on all keyPress strokes. Only processes
// character keys (as long as no modifier key is in use).
//
Formatter.prototype._keyPress = function (evt) {
// The first thing we need is the character code
var k, isSpecial;
// Mozilla will trigger on special keys and assign the the value 0
// We want to use that 0 rather than the keyCode it assigns.
if (evt.which) {
k = evt.which;
} else {
k = evt.keyCode;
isSpecial = utils.isSpecialKey(k);
}
// Process the keyCode and prevent default
if (!utils.isDelKey(k) && !isSpecial && !utils.isModifier(evt)) {
this._processKey(String.fromCharCode(k), false);
return utils.preventDefault(evt);
}
};
//
// @private
// Handler called on paste event.
//
Formatter.prototype._paste = function (evt) {
// Process the clipboard paste and prevent default
this._processKey(utils.getClip(evt), false);
return utils.preventDefault(evt);
};
//
// @private
// Handle called on focus event.
//
Formatter.prototype._focus = function () {
// Wrapped in timeout so that we can grab input selection
var self = this;
setTimeout(function () {
// Grab selection
var selection = inptSel.get(self.el);
// Char check
var isAfterStart = selection.end > self.focus,
isFirstChar = selection.end === 0;
// If clicked in front of start, refocus to start
if (isAfterStart || isFirstChar) {
inptSel.set(self.el, self.focus);
}
}, 0);
};
//
// @private
// Using the provided key information, alter el value.
//
Formatter.prototype._processKey = function (chars, delKey,ingoreCaret) {
// Get current state
this.sel = inptSel.get(this.el);
this.val = this.el.value;
// Init values
this.delta = 0;
// If chars were highlighted, we need to remove them
if (this.sel.begin !== this.sel.end) {
this.delta = (-1) * Math.abs(this.sel.begin - this.sel.end);
this.val = utils.removeChars(this.val, this.sel.begin, this.sel.end);
}
// Delete key (moves opposite direction)
else if (delKey && delKey == 46) {
this._delete();
// or Backspace and not at start
} else if (delKey && this.sel.begin - 1 >= 0) {
this.val = utils.removeChars(this.val, this.sel.end -1, this.sel.end);
this.delta = -1;
// or Backspace and at start - exit
} else if (delKey) {
return true;
}
// If the key is not a del key, it should convert to a str
if (!delKey) {
// Add char at position and increment delta
this.val = utils.addChars(this.val, chars, this.sel.begin);
this.delta += chars.length;
}
// Format el.value (also handles updating caret position)
this._formatValue(ingoreCaret);
};
//
// @private
// Deletes the character in front of it
//
Formatter.prototype._delete = function () {
// Adjust focus to make sure its not on a formatted char
while (this.chars[this.sel.begin]) {
this._nextPos();
}
// As long as we are not at the end
if (this.sel.begin < this.val.length) {
// We will simulate a delete by moving the caret to the next char
// and then deleting
this._nextPos();
this.val = utils.removeChars(this.val, this.sel.end -1, this.sel.end);
this.delta = -1;
}
};
//
// @private
// Quick helper method to move the caret to the next pos
//
Formatter.prototype._nextPos = function () {
this.sel.end ++;
this.sel.begin ++;
};
//
// @private
// Alter element value to display characters matching the provided
// instance pattern. Also responsible for updating
//
Formatter.prototype._formatValue = function (ignoreCaret) {
// Set caret pos
this.newPos = this.sel.end + this.delta;
// Remove all formatted chars from val
this._removeChars();
// Switch to first matching pattern based on val
this._updatePattern();
// Validate inputs
this._validateInpts();
// Add formatted characters
this._addChars();
// Set value and adhere to maxLength
this.el.value = this.val.substr(0, this.mLength);
// Set new caret position
if ((typeof ignoreCaret) === 'undefined' || ignoreCaret === false) {
inptSel.set(this.el, this.newPos);
}
};
//
// @private
// Remove all formatted before and after a specified pos
//
Formatter.prototype._removeChars = function () {
// Delta shouldn't include placeholders
if (this.sel.end > this.focus) {
this.delta += this.sel.end - this.focus;
}
// Account for shifts during removal
var shift = 0;
// Loop through all possible char positions
for (var i = 0; i <= this.mLength; i++) {
// Get transformed position
var curChar = this.chars[i],
curHldr = this.hldrs[i],
pos = i + shift,
val;
// If after selection we need to account for delta
pos = (i >= this.sel.begin) ? pos + this.delta : pos;
val = this.val.charAt(pos);
// Remove char and account for shift
if (curChar && curChar == val || curHldr && curHldr == val) {
this.val = utils.removeChars(this.val, pos, pos + 1);
shift--;
}
}
// All hldrs should be removed now
this.hldrs = {};
// Set focus to last character
this.focus = this.val.length;
};
//
// @private
// Make sure all inpts are valid, else remove and update delta
//
Formatter.prototype._validateInpts = function () {
// Loop over each char and validate
for (var i = 0; i < this.val.length; i++) {
// Get char inpt type
var inptType = this.inpts[i];
// Checks
var isBadType = !inptRegs[inptType],
isInvalid = !isBadType && !inptRegs[inptType].test(this.val.charAt(i)),
inBounds = this.inpts[i];
// Remove if incorrect and inbounds
if ((isBadType || isInvalid) && inBounds) {
this.val = utils.removeChars(this.val, i, i + 1);
this.focusStart--;
this.newPos--;
this.delta--;
i--;
}
}
};
//
// @private
// Loop over val and add formatted chars as necessary
//
Formatter.prototype._addChars = function () {
if (this.opts.persistent) {
// Loop over all possible characters
for (var i = 0; i <= this.mLength; i++) {
if (!this.val.charAt(i)) {
// Add placeholder at pos
this.val = utils.addChars(this.val, this.opts.placeholder, i);
this.hldrs[i] = this.opts.placeholder;
}
this._addChar(i);
}
// Adjust focus to make sure its not on a formatted char
while (this.chars[this.focus]) {
this.focus++;
}
} else {
// Avoid caching val.length, as it changes during manipulations
for (var j = 0; j <= this.val.length; j++) {
// When moving backwards there are some race conditions where we
// dont want to add the character
if (this.delta <= 0 && (j == this.focus)) { return true; }
this._addChar(j);
}
}
};
//
// @private
// Add formattted char at position
//
Formatter.prototype._addChar = function (i) {
// If char exists at position
var chr = this.chars[i];
if (!chr) { return true; }
// If chars are added in between the old pos and new pos
// we need to increment pos and delta
if (utils.isBetween(i, [this.sel.begin -1, this.newPos +1])) {
this.newPos ++;
this.delta ++;
}
// If character added before focus, incr
if (i <= this.focus) {
this.focus++;
}
// Updateholder
if (this.hldrs[i]) {
delete this.hldrs[i];
this.hldrs[i + 1] = this.opts.placeholder;
}
// Update value
this.val = utils.addChars(this.val, chr, i);
};
//
// @private
// Create a patternSpec for passing into patternMatcher that
// has exactly one catch all pattern.
//
Formatter.prototype._specFromSinglePattern = function (patternStr) {
return [{ '*': patternStr }];
};
// Define module
var pattern = {};
// Match information
var DELIM_SIZE = 4;
// Our regex used to parse
var regexp = new RegExp('{{([^}]+)}}', 'g');
//
// Helper method to parse pattern str
//
var getMatches = function (pattern) {
// Populate array of matches
var matches = [],
match;
while(match = regexp.exec(pattern)) {
matches.push(match);
}
return matches;
};
//
// Create an object holding all formatted characters
// with corresponding positions
//
pattern.parse = function (pattern) {
// Our obj to populate
var info = { inpts: {}, chars: {} };
// Pattern information
var matches = getMatches(pattern),
pLength = pattern.length;
// Counters
var mCount = 0,
iCount = 0,
i = 0;
// Add inpts, move to end of match, and process
var processMatch = function (val) {
var valLength = val.length;
for (var j = 0; j < valLength; j++) {
info.inpts[iCount] = val.charAt(j);
iCount++;
}
mCount ++;
i += (val.length + DELIM_SIZE - 1);
};
// Process match or add chars
for (i; i < pLength; i++) {
if (i == matches[mCount].index) {
processMatch(matches[mCount][1]);
} else {
info.chars[i - (mCount * DELIM_SIZE)] = pattern.charAt(i);
}
}
// Set mLength and return
info.mLength = i - (mCount * DELIM_SIZE);
return info;
};
//
// Parse a matcher string into a RegExp. Accepts valid regular
// expressions and the catchall '*'.
// @private
//
var parseMatcher = function (matcher) {
if (matcher === '*') {
return /.*/;
}
return new RegExp(matcher);
};
//
// Parse a pattern spec and return a function that returns a pattern
// based on user input. The first matching pattern will be chosen.
// Pattern spec format:
// Array [
// Object: { Matcher(RegExp String) : Pattern(Pattern String) },
// ...
// ]
function patternMatcher (patternSpec) {
var matchers = [],
patterns = [];
// Iterate over each pattern in order.
utils.forEach(patternSpec, function (patternMatcher) {
// Process single property object to obtain pattern and matcher.
utils.forEach(patternMatcher, function (patternStr, matcherStr) {
var parsedPattern = pattern.parse(patternStr),
regExpMatcher = parseMatcher(matcherStr);
matchers.push(regExpMatcher);
patterns.push(parsedPattern);
// Stop after one iteration.
return false;
});
});
var getPattern = function (input) {
var matchedIndex;
utils.forEach(matchers, function (matcher, index) {
if (matcher.test(input)) {
matchedIndex = index;
return false;
}
});
return matchedIndex === undefined ? null : patterns[matchedIndex];
};
return {
getPattern: getPattern,
patterns: patterns,
matchers: matchers
};
}
// Define module
var inptSel = {};
//
// Get begin and end positions of selected input. Return 0's
// if there is no selectiion data
//
inptSel.get = function (el) {
// If normal browser return with result
if (typeof el.selectionStart == "number") {
return {
begin: el.selectionStart,
end: el.selectionEnd
};
}
// Uh-Oh. We must be IE. Fun with TextRange!!
var range = document.selection.createRange();
// Determine if there is a selection
if (range && range.parentElement() == el) {
var inputRange = el.createTextRange(),
endRange = el.createTextRange(),
length = el.value.length;
// Create a working TextRange for the input selection
inputRange.moveToBookmark(range.getBookmark());
// Move endRange begin pos to end pos (hence endRange)
endRange.collapse(false);
// If we are at the very end of the input, begin and end
// must both be the length of the el.value
if (inputRange.compareEndPoints("StartToEnd", endRange) > -1) {
return { begin: length, end: length };
}
// Note: moveStart usually returns the units moved, which
// one may think is -length, however, it will stop when it
// gets to the begin of the range, thus giving us the
// negative value of the pos.
return {
begin: -inputRange.moveStart("character", -length),
end: -inputRange.moveEnd("character", -length)
};
}
//Return 0's on no selection data
return { begin: 0, end: 0 };
};
//
// Set the caret position at a specified location
//
inptSel.set = function (el, pos) {
// If normal browser
if (el.setSelectionRange) {
el.focus();
el.setSelectionRange(pos,pos);
// IE = TextRange fun
} else if (el.createTextRange) {
var range = el.createTextRange();
range.collapse(true);
range.moveEnd('character', pos);
range.moveStart('character', pos);
range.select();
}
};
// Define module
var utils = {};
// Useragent info for keycode handling
var uAgent = (typeof navigator !== 'undefined') ? navigator.userAgent : null,
iPhone = /iphone/i.test(uAgent);
//
// Shallow copy properties from n objects to destObj
//
utils.extend = function (destObj) {
for (var i = 1; i < arguments.length; i++) {
for (var key in arguments[i]) {
destObj[key] = arguments[i][key];
}
}
return destObj;
};
//
// Add a given character to a string at a defined pos
//
utils.addChars = function (str, chars, pos) {
return str.substr(0, pos) + chars + str.substr(pos, str.length);
};
//
// Remove a span of characters
//
utils.removeChars = function (str, start, end) {
return str.substr(0, start) + str.substr(end, str.length);
};
//
// Return true/false is num false between bounds
//
utils.isBetween = function (num, bounds) {
bounds.sort(function(a,b) { return a-b; });
return (num > bounds[0] && num < bounds[1]);
};
//
// Helper method for cross browser event listeners
//
utils.addListener = function (el, evt, handler) {
return (typeof el.addEventListener != "undefined")
? el.addEventListener(evt, handler, false)
: el.attachEvent('on' + evt, handler);
};
//
// Helper method for cross browser implementation of preventDefault
//
utils.preventDefault = function (evt) {
return (evt.preventDefault) ? evt.preventDefault() : (evt.returnValue = false);
};
//
// Helper method for cross browser implementation for grabbing
// clipboard data
//
utils.getClip = function (evt) {
if (evt.clipboardData) { return evt.clipboardData.getData('Text'); }
if (window.clipboardData) { return window.clipboardData.getData('Text'); }
};
//
// Returns true/false if k is a del key
//
utils.isDelKey = function (k) {
return k === 8 || k === 46 || (iPhone && k === 127);
};
//
// Returns true/false if k is an arrow key
//
utils.isSpecialKey = function (k) {
var codes = {
'9' : 'tab',
'13': 'enter',
'35': 'end',
'36': 'home',
'37': 'leftarrow',
'38': 'uparrow',
'39': 'rightarrow',
'40': 'downarrow',
'116': 'F5'
};
// If del or special key
return codes[k];
};
//
// Returns true/false if modifier key is held down
//
utils.isModifier = function (evt) {
return evt.ctrlKey || evt.altKey || evt.metaKey;
};
//
// Iterates over each property of object or array.
//
utils.forEach = function (collection, callback, thisArg) {
if (collection.hasOwnProperty("length")) {
for (var index = 0, len = collection.length; index < len; index++) {
if (callback.call(thisArg, collection[index], index, collection) === false) {
break;
}
}
} else {
for (var key in collection) {
if (collection.hasOwnProperty(key)) {
if (callback.call(thisArg, collection[key], key, collection) === false) {
break;
}
}
}
}
};
return Formatter;
});