/*!
* jQuery highlightTextarea
* Copyright 2014-2015 Damien "Mistic" Sorel (http://www.strangeplanet.fr)
* Licensed under MIT (http://opensource.org/licenses/MIT)
*/
//= require jquery
(function($){
"use strict";
var mouseOverElement = null;
var isNumeric = function(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
};
// Highlighter CLASS DEFINITON
// ===============================
var Highlighter = function($el, options) {
// global variables
this.settings = $.extend({}, Highlighter.DEFAULTS);
this.scrollbarWidth = Utilities.getScrollbarWidth();
this.isInput = $el[0].tagName.toLowerCase()=='input';
this.active = false;
this.matches = [];
// build HTML
this.$el = $el;
this.$el.wrap('
');
this.$main = this.$el.parent();
this.$main.prepend('');
this.$container = this.$main.children().first();
this.$highlighter = this.$container.children();
this.setOptions(options);
// set id
if (this.settings.id) {
this.$main[0].id = this.settings.id;
}
// resizable
if (this.settings.resizable) {
this.applyResizable();
}
// run
this.updateCss();
this.bindDocumentEvents();
this.bindEvents();
this.highlight();
};
Highlighter.DEFAULTS = {
words: {},
ranges: {},
color: '#ffff00',
caseSensitive: true,
wordsOnly: false,
resizable: false,
resizableOptions: {},
id: '',
debug: false
};
// PUBLIC METHODS
// ===============================
/*
* Refresh highlight
*/
Highlighter.prototype.highlight = function() {
var text = this.$el.val(),
that = this;
that.spacer = '';
if (this.settings.wordsOnly ) {
that.spacer = '\\b';
}
function htmlDecode(input) {
return $('').text(input).html();
}
// Encode text before inserting into so that the textarea and
// overlay don't get out if sync when the textarea contains something
// HTML (e.g. "&" or
).
text = htmlDecode(text);
var matches = [];
$.each(this.settings.words, function(color, words) {
var wordsRe = htmlDecode(words.join("|"));
var re = that.spacer + '(' + wordsRe + ')' + that.spacer;
var regex = new RegExp(re, that.regParam);
var wordMatches = text.match(regex);
if (wordMatches) {
var evaluated = [];
$.each(words, function(index, match) {
match = htmlDecode(match);
matches.push(match);
if (evaluated.indexOf(match) === -1) {
text = text.replace(
new RegExp(match, that.regParam),
function(innerMatch, start, contents) {
var encodedMatch = innerMatch
.replace(/[&"<>]/g, function (c) {
return {
'&': "&",
'"': """,
'<': "<",
'>': ">"
}[c];
});
return '' + encodedMatch + '';
}
);
evaluated.push(match);
}
});
}
});
$.each(this.settings.ranges, function(i, range) {
if (range.start < text.length) {
text = Utilities.strInsert(text, range.end, '');
var mark = '";
text = Utilities.strInsert(text, range.start, mark);
}
});
if (matches.length !== this.matches.length) {
this.matches = matches;
var matchesChangedEvent = $.Event('matchesChanged');
matchesChangedEvent.matches = this.matches;
this.$el.trigger(matchesChangedEvent);
}
this.$highlighter.html(text);
this.updateSizePosition();
};
/*
* Change highlighted words
* @param words {mixed}
*/
Highlighter.prototype.setWords = function(words) {
this.setOptions({ words: words, ranges: {} });
};
/*
* Change highlighted ranges
* @param ranges {mixed}
*/
Highlighter.prototype.setRanges = function(ranges) {
this.setOptions({ words: {}, ranges: ranges });
};
/*
* Enable highlight and events
*/
Highlighter.prototype.enable = function() {
this.bindEvents();
this.highlight();
};
/*
* Disable highlight and events
*/
Highlighter.prototype.disable = function() {
this.unbindEvents();
this.$highlighter.empty();
};
/*
* Remove the plugin
*/
Highlighter.prototype.destroy = function() {
this.disable();
Utilities.cloneCss(this.$container, this.$el, [
'background-image','background-color','background-position','background-repeat',
'background-origin','background-clip','background-size','background-attachment'
]);
this.$main.replaceWith(this.$el);
this.$el.removeData('highlighter');
};
// PRIVATE METHODS
// ===============================
/*
* Change options
* @param options {object}
*/
Highlighter.prototype.setOptions = function(options) {
if (typeof options != 'object' || $.isEmptyObject(options)) {
return;
}
$.extend(this.settings, options);
this.regParam = this.settings.caseSensitive ? 'gm' : 'gim';
if (!$.isEmptyObject(this.settings.words)) {
this.settings.words = Utilities.cleanWords(this.settings.words, this.settings.color);
this.settings.ranges = {};
}
else if (!$.isEmptyObject(this.settings.ranges)) {
this.settings.words = {};
this.settings.ranges = Utilities.cleanRanges(this.settings.ranges, this.settings.color);
}
if (this.settings.debug) {
this.$main.addClass('debug');
}
else {
this.$main.removeClass('debug');
}
if (this.active) {
this.highlight();
}
};
/*
* Attach event listeners for document-level events. These should only be bound once, not every time the plugin is used.
*/
Highlighter.prototype.bindDocumentEvents = function() {
if(Highlighter.documentEventsBound) {
return;
}
//Trigger simulated mouseout and mouseover events on highlightTextarea mark elements.
$(document).bind('mousemove', function(e) {
var mouseX = e.pageX;
var mouseY = e.pageY;
var lastMouseOverElement = mouseOverElement;
mouseOverElement = null;
//Mouse can only be "over" one element at a time; loop through all highlightTextarea mark elements until we find one that is moused over.
$('.highlightTextarea mark').each(function(index) {
var $this = $(this);
var pos = $this.offset();
var top = pos.top;
var left = pos.left;
var height = $this.height();
var width = $this.width();
if (mouseX >= left && mouseY >= top && mouseX <= left + width && mouseY <= top + height) {
mouseOverElement = this;
return;
}
});
if(mouseOverElement != lastMouseOverElement) {
if(lastMouseOverElement != null) {
$(lastMouseOverElement).trigger('mouseout');
}
if(mouseOverElement != null) {
$(mouseOverElement).trigger('mouseover');
}
}
});
Highlighter.documentEventsBound = true;
};
/*
* Attach event listeners
*/
Highlighter.prototype.bindEvents = function() {
if (this.active) {
return;
}
this.active = true;
var that = this;
// prevent positioning errors by always focusing the textarea
this.$highlighter.bind({
'this.highlighter': function() {
that.$el.focus();
}
});
// add triggers to textarea
this.$el.bind({
'input.highlightTextarea': Utilities.throttle(function() {
this.highlight();
}, 100, this),
'resize.highlightTextarea': Utilities.throttle(function() {
this.updateSizePosition(true);
}, 50, this),
'scroll.highlightTextarea select.highlightTextarea': Utilities.throttle(function() {
this.updateSizePosition();
}, 50, this)
});
if (this.isInput) {
this.$el.bind({
// Prevent Cmd-Left Arrow and Cmd-Right Arrow on Mac strange behavior
'keydown.highlightTextarea keypress.highlightTextarea keyup.highlightTextarea': function() {
setTimeout($.proxy(that.updateSizePosition, that), 1);
},
// Force Chrome behavior on all browsers: reset input position on blur
'blur.highlightTextarea': function() {
this.value = this.value;
this.scrollLeft = 0;
that.updateSizePosition.call(that);
}
});
}
};
/*
* Detach event listeners
*/
Highlighter.prototype.unbindEvents = function() {
if (!this.active) {
return;
}
this.active = false;
this.$highlighter.off('.highlightTextarea');
this.$el.off('.highlightTextarea');
};
/*
* Update CSS of wrapper and containers
*/
Highlighter.prototype.updateCss = function() {
// the main container has the same size and position than the original textarea
Utilities.cloneCss(this.$el, this.$main, [
'float','vertical-align'
]);
this.$main.css({
'width': this.$el.outerWidth(true),
'height': this.$el.outerHeight(true)
});
// the highlighter container is positionned at "real" top-left corner of the textarea and takes its background
Utilities.cloneCss(this.$el, this.$container, [
'background-image','background-color','background-position','background-repeat',
'background-origin','background-clip','background-size','background-attachment',
'padding-top','padding-right','padding-bottom','padding-left'
]);
this.$container.css({
'top': Utilities.toPx(this.$el.css('margin-top')) + Utilities.toPx(this.$el.css('border-top-width')),
'left': Utilities.toPx(this.$el.css('margin-left')) + Utilities.toPx(this.$el.css('border-left-width')),
'width': this.$el.width(),
'height': this.$el.height()
});
// the highlighter has the same size than the "inner" textarea and must have the same font properties
Utilities.cloneCss(this.$el, this.$highlighter, [
'font-size','font-family','font-style','font-weight','font-variant','font-stretch',
'vertical-align','word-spacing','text-align','letter-spacing', 'text-rendering'
]);
// now make the textarea transparent to see the highlighter through
this.$el.css({
'background': 'none'
});
};
/*
* Apply jQueryUi Resizable if available
*/
Highlighter.prototype.applyResizable = function() {
if (jQuery.ui) {
var resizableOptionsDefaults = {
'handles': 'se',
'resize': Utilities.throttle(function() {
this.updateSizePosition(true);
}, 50, this)
};
var resizableOptions = $.extend({}, resizableOptionsDefaults, this.settings.resizableOptions);
this.$el.resizable(resizableOptions);
}
};
/*
* Update size and position of the highlighter
* @param forced {boolean} true to resize containers
*/
Highlighter.prototype.updateSizePosition = function(forced) {
// resize containers
if (forced) {
this.$main.css({
'width': this.$el.outerWidth(true),
'height': this.$el.outerHeight(true)
});
this.$container.css({
'width': this.$el.width(),
'height': this.$el.height()
});
}
var padding = 0, width;
if (!this.isInput) {
// account for vertical scrollbar width
if (this.$el.css('overflow') == 'scroll' ||
this.$el.css('overflow-y') == 'scroll' ||
(
this.$el.css('overflow') != 'hidden' &&
this.$el.css('overflow-y') != 'hidden' &&
this.$el[0].clientHeight < this.$el[0].scrollHeight
)
) {
padding = this.scrollbarWidth;
}
width = this.$el.width()-padding;
}
else {
// TODO: There's got to be a better way of going about this than just using 99999px...
width = 99999;
}
this.$highlighter.css({
'width': width,
'height': this.$el.height() + this.$el.scrollTop(),
'top': -this.$el.scrollTop(),
'left': -this.$el.scrollLeft()
});
};
// Utilities CLASS DEFINITON
// ===============================
var Utilities = function(){};
/*
* Get the scrollbar with on this browser
*/
Utilities.getScrollbarWidth = function() {
var parent = $('').appendTo('body'),
child = parent.children(),
width = child.innerWidth() - child.height(100).innerWidth();
parent.remove();
return width;
};
/*
* Copy a list of CSS properties from one object to another
* @param from {jQuery}
* @param to {jQuery}
* @param what {string[]}
*/
Utilities.cloneCss = function(from, to, what) {
for (var i=0, l=what.length; i ').appendTo('body');
value = Math.round(parseFloat(value.replace('em', '')) * el.height());
el.remove();
return value;
}
else if (value != value.replace('px', '')) {
return parseInt(value.replace('px', ''));
}
else {
return parseInt(value);
}
};
/*
* Converts HTMl entities
* @param str {string}
* @return {string}
*/
Utilities.htmlEntities = function(str) {
if (str) {
return $('').text(str).html();
}
else {
return '';
}
};
/*
* Inserts a string in another string at given position
* @param string {string}
* @param index {int}
* @param value {string}
* @return {string}
*/
Utilities.strInsert = function(string, index, value) {
return string.slice(0, index) + value + string.slice(index);
};
/*
* Apply throttling to a callback
* @param callback {function}
* @param delay {int} milliseconds
* @param context {object|null}
* @return {function}
*/
Utilities.throttle = function(callback, delay, context) {
var state = {
pid: null,
last: 0
};
return function() {
var elapsed = new Date().getTime() - state.last,
args = arguments,
that = this;
function exec() {
state.last = new Date().getTime();
if (context) {
return callback.apply(context, Array.prototype.slice.call(args));
}
else {
return callback.apply(that, Array.prototype.slice.call(args));
}
}
if (elapsed > delay) {
return exec();
}
else {
clearTimeout(state.pid);
state.pid = setTimeout(exec, delay - elapsed);
}
};
};
/*
* Formats a list of words into a hash of arrays (Color => Words list)
* @param words {mixed}
* @param color {string} default color
* @return {object[]}
*/
Utilities.cleanWords = function(words, color) {
var out = {};
if (!$.isArray(words)) {
words = [words];
}
for (var i=0, l=words.length; i Ranges list)
* @param ranges {mixed}
* @param color {string} default color
* @return {object[]}
*/
Utilities.cleanRanges = function(ranges, color) {
var out = [];
if ($.isPlainObject(ranges) || isNumeric(ranges[0])) {
ranges = [ranges];
}
for (var i=0, l=ranges.length; i= range.end) {
$.error('Invalid range end/start');
}
if (range.start < current) {
$.error('Ranges overlap');
}
current = range.end;
});
out.reverse();
return out;
};
// JQUERY PLUGIN DEFINITION
// ===============================
$.fn.highlightTextarea = function(option) {
var args = arguments;
return this.each(function() {
var $this = $(this),
data = $this.data('highlighter'),
options = typeof option == 'object' && option;
if (!data && option == 'destroy') {
return;
}
if (!data) {
data = new Highlighter($this, options);
$this.data('highlighter', data);
}
if (typeof option == 'string') {
data[option].apply(data, Array.prototype.slice.call(args, 1));
}
});
};
}(window.jQuery || window.Zepto));