Skip to content

Instantly share code, notes, and snippets.

@dilkhush
Created June 18, 2015 11:20
Show Gist options
  • Save dilkhush/5933ec99adf8cf28ec08 to your computer and use it in GitHub Desktop.
Save dilkhush/5933ec99adf8cf28ec08 to your computer and use it in GitHub Desktop.
(function (global) {
'use strict';
var
/**
* Attribute added by default to every highlight.
* @type {string}
*/
DATA_ATTR = 'data-highlighted',
/**
* Attribute used to group highlight wrappers.
* @type {string}
*/
TIMESTAMP_ATTR = 'data-timestamp',
NODE_TYPE = {
ELEMENT_NODE: 1,
TEXT_NODE: 3
},
/**
* Don't highlight content of these tags.
* @type {string[]}
*/
IGNORE_TAGS = [
'SCRIPT', 'STYLE', 'SELECT', 'OPTION', 'BUTTON', 'OBJECT', 'APPLET', 'VIDEO', 'AUDIO', 'CANVAS', 'EMBED',
'PARAM', 'METER', 'PROGRESS'
];
/**
* Returns true if elements a i b have the same color.
* @param {Node} a
* @param {Node} b
* @returns {boolean}
*/
function haveSameColor(a, b) {
return dom(a).color() === dom(b).color();
}
/**
* Fills undefined values in obj with default properties with the same name from source object.
* @param {object} obj - target object
* @param {object} source - source object with default values
* @returns {object}
*/
function defaults(obj, source) {
obj = obj || {};
for (var prop in source) {
if (source.hasOwnProperty(prop) && obj[prop] === void 0) {
obj[prop] = source[prop];
}
}
return obj;
}
/**
* Returns array without duplicated values.
* @param {Array} arr
* @returns {Array}
*/
function unique(arr) {
return arr.filter(function (value, idx, self) {
return self.indexOf(value) === idx;
});
}
/**
* Takes range object as parameter and refines it boundaries
* @param range
* @returns {object} refined boundaries and initial state of highlighting algorithm.
*/
function refineRangeBoundaries(range) {
var startContainer = range.startContainer,
endContainer = range.endContainer,
ancestor = range.commonAncestorContainer,
goDeeper = true;
if (range.endOffset === 0) {
while (!endContainer.previousSibling && endContainer.parentNode !== ancestor) {
endContainer = endContainer.parentNode;
}
endContainer = endContainer.previousSibling;
} else if (endContainer.nodeType === NODE_TYPE.TEXT_NODE) {
if (range.endOffset < endContainer.nodeValue.length) {
endContainer.splitText(range.endOffset);
}
} else if (range.endOffset > 0) {
endContainer = endContainer.childNodes.item(range.endOffset - 1);
}
if (startContainer.nodeType === NODE_TYPE.TEXT_NODE) {
if (range.startOffset === startContainer.nodeValue.length) {
goDeeper = false;
} else if (range.startOffset > 0) {
startContainer = startContainer.splitText(range.startOffset);
if (endContainer === startContainer.previousSibling) {
endContainer = startContainer;
}
}
} else if (range.startOffset < startContainer.childNodes.length) {
startContainer = startContainer.childNodes.item(range.startOffset);
} else {
startContainer = startContainer.nextSibling;
}
return {
startContainer: startContainer,
endContainer: endContainer,
goDeeper: goDeeper
};
}
/**
* Sorts array of DOM elements by its depth in DOM tree.
* @param {HTMLElement[]} arr - array to sort.
* @param {boolean} descending - order of sort.
*/
function sortByDepth(arr, descending) {
arr.sort(function (a, b) {
return dom(descending ? b : a).parents().length - dom(descending ? a : b).parents().length;
});
}
/**
* Groups given highlights by timestamp.
* @param {Array} highlights
* @returns {Array} Grouped highlights.
*/
function groupHighlights(highlights) {
var order = [],
chunks = {},
grouped = [];
highlights.forEach(function (hl) {
var timestamp = hl.getAttribute(TIMESTAMP_ATTR);
if (typeof chunks[timestamp] === 'undefined') {
chunks[timestamp] = [];
order.push(timestamp);
}
chunks[timestamp].push(hl);
});
order.forEach(function (timestamp) {
var group = chunks[timestamp];
grouped.push({
chunks: group,
timestamp: timestamp,
toString: function () {
return group.map(function (h) {
return h.textContent;
}).join('');
}
});
});
return grouped;
}
/**
* Utility functions to make DOM manipulation easier.
* @param {Node|HTMLElement} [el] - base DOM element to manipulate
* @returns {object}
*/
var dom = function (el) {
return /** @lends dom **/ {
/**
* Adds class to element.
* @param {string} className
*/
addClass: function (className) {
if (el.classList) {
el.classList.add(className);
} else {
el.className += ' ' + className;
}
},
/**
* Removes class from element.
* @param {string} className
*/
removeClass: function (className) {
if (el.classList) {
el.classList.remove(className);
} else {
el.className = el.className.replace(
new RegExp('(^|\\b)' + className + '(\\b|$)', 'gi'), ' '
);
}
},
/**
* Prepends child nodes to base element.
* @param {Node[]} nodesToPrepend
*/
prepend: function (nodesToPrepend) {
var nodes = Array.prototype.slice.call(nodesToPrepend),
i = nodes.length;
while (i--) {
el.insertBefore(nodes[i], el.firstChild);
}
},
/**
* Appends child nodes to base element.
* @param {Node[]} nodesToAppend
*/
append: function (nodesToAppend) {
var nodes = Array.prototype.slice.call(nodesToAppend);
for (var i = 0, len = nodes.length; i < len; ++i) {
el.appendChild(nodes[i]);
}
},
/**
* Inserts base element after refEl.
* @param {Node} refEl - node after which base element will be inserted
* @returns {Node} - inserted element
*/
insertAfter: function (refEl) {
return refEl.parentNode.insertBefore(el, refEl.nextSibling);
},
/**
* Inserts base element before refEl.
* @param {Node} refEl - node before which base element will be inserted
* @returns {Node} - inserted element
*/
insertBefore: function (refEl) {
return refEl.parentNode.insertBefore(el, refEl);
},
/**
* Removes base element from DOM.
*/
remove: function () {
el.parentNode.removeChild(el);
el = null;
},
/**
* Returns true if base element contains given child.
* @param {Node|HTMLElement} child
* @returns {boolean}
*/
contains: function (child) {
return el !== child && el.contains(child);
},
/**
* Wraps base element in wrapper element.
* @param {HTMLElement} wrapper
* @returns {HTMLElement} wrapper element
*/
wrap: function (wrapper) {
if (el.parentNode) {
el.parentNode.insertBefore(wrapper, el);
}
wrapper.appendChild(el);
return wrapper;
},
/**
* Unwraps base element.
* @returns {Node[]} - child nodes of unwrapped element.
*/
unwrap: function () {
var nodes = Array.prototype.slice.call(el.childNodes),
wrapper;
nodes.forEach(function (node) {
wrapper = node.parentNode;
dom(node).insertBefore(node.parentNode);
dom(wrapper).remove();
});
return nodes;
},
/**
* Returns array of base element parents.
* @returns {HTMLElement[]}
*/
parents: function () {
var parent, path = [];
while (!!(parent = el.parentNode)) {
path.push(parent);
el = parent;
}
return path;
},
/**
* Normalizes text nodes within base element, ie. merges sibling text nodes and assures that every
* element node has only one text node.
* It should does the same as standard element.normalize, but IE implements it incorrectly.
*/
normalizeTextNodes: function () {
if (!el) {
return;
}
if (el.nodeType === NODE_TYPE.TEXT_NODE) {
while (el.nextSibling && el.nextSibling.nodeType === NODE_TYPE.TEXT_NODE) {
el.nodeValue += el.nextSibling.nodeValue;
el.parentNode.removeChild(el.nextSibling);
}
} else {
dom(el.firstChild).normalizeTextNodes();
}
dom(el.nextSibling).normalizeTextNodes();
},
/**
* Returns element background color.
* @returns {CSSStyleDeclaration.backgroundColor}
*/
color: function () {
return el.style.backgroundColor;
},
/**
* Creates dom element from given html string.
* @param {string} html
* @returns {NodeList}
*/
fromHTML: function (html) {
var div = document.createElement('div');
div.innerHTML = html;
return div.childNodes;
},
/**
* Returns first range of the window of base element.
* @returns {Range}
*/
getRange: function () {
var selection = dom(el).getSelection(),
range;
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
return range;
},
/**
* Removes all ranges of the window of base element.
*/
removeAllRanges: function () {
var selection = dom(el).getSelection();
selection.removeAllRanges();
},
/**
* Returns selection object of the window of base element.
* @returns {Selection}
*/
getSelection: function () {
return dom(el).getWindow().getSelection();
},
/**
* Returns window of the base element.
* @returns {Window}
*/
getWindow: function () {
return dom(el).getDocument().defaultView;
},
/**
* Returns document of the base element.
* @returns {HTMLDocument}
*/
getDocument: function () {
// if ownerDocument is null then el is the document itself.
return el.ownerDocument || el;
}
};
};
function bindEvents(el, scope) {
el.addEventListener('mouseup', scope.highlightHandler.bind(scope));
el.addEventListener('touchend', scope.highlightHandler.bind(scope));
}
function unbindEvents(el, scope) {
el.removeEventListener('mouseup', scope.highlightHandler.bind(scope));
el.removeEventListener('touchend', scope.highlightHandler.bind(scope));
}
/**
* Creates TextHighlighter instance and binds to given DOM elements.
* @param {HTMLElement} element - DOM element to which highlighted will be applied.
* @param {object} [options] - additional options.
* @param {string} options.color - highlight color.
* @param {string} options.highlightedClass - class added to highlight, 'highlighted' by default.
* @param {string} options.contextClass - class added to element to which highlighter is applied,
* 'highlighter-context' by default.
* @param {function} options.onRemoveHighlight - function called before highlight is removed. Highlight is
* passed as param. Function should return true if highlight should be removed, or false - to prevent removal.
* @param {function} options.onBeforeHighlight - function called before highlight is created. Range object is
* passed as param. Function should return true to continue processing, or false - to prevent highlighting.
* @param {function} options.onAfterHighlight - function called after highlight is created. Array of created
* wrappers is passed as param.
* @class TextHighlighter
*/
function TextHighlighter(element, options) {
if (!element) {
throw 'Missing anchor element';
}
this.el = element;
this.options = defaults(options, {
color: '#ffff7b',
highlightedClass: 'highlighted',
contextClass: 'highlighter-context',
onRemoveHighlight: function () { return true; },
onBeforeHighlight: function () { return true; },
onAfterHighlight: function () { }
});
dom(this.el).addClass(this.options.contextClass);
bindEvents(this.el, this);
}
/**
* Permanently disables highlighting.
* Unbinds events and remove context element class.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.destroy = function () {
unbindEvents(this.el, this);
dom(this.el).removeClass(this.options.contextClass);
};
TextHighlighter.prototype.highlightHandler = function () {
this.doHighlight();
};
/**
* Highlights current range.
* @param {boolean} keepRange - Don't remove range after highlighting. Default: false.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.doHighlight = function (keepRange, range) {
var range = range ? range : dom(this.el).getRange(),
wrapper,
createdHighlights,
normalizedHighlights,
timestamp;
if (!range || range.collapsed) {
return;
}
if (this.options.onBeforeHighlight(range) === true) {
timestamp = +new Date();
wrapper = TextHighlighter.createWrapper(this.options);
wrapper.setAttribute(TIMESTAMP_ATTR, timestamp);
createdHighlights = this.highlightRange(range, wrapper);
normalizedHighlights = this.normalizeHighlights(createdHighlights);
this.options.onAfterHighlight(range, normalizedHighlights, timestamp);
}
if (!keepRange) {
dom(this.el).removeAllRanges();
}
};
/**
* Highlights range.
* Wraps text of given range object in wrapper element.
* @param {Range} range
* @param {HTMLElement} wrapper
* @returns {Array} - array of created highlights.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.highlightRange = function (range, wrapper) {
if (!range || range.collapsed) {
return [];
}
var result = refineRangeBoundaries(range),
startContainer = result.startContainer,
endContainer = result.endContainer,
goDeeper = result.goDeeper,
done = false,
node = startContainer,
highlights = [],
highlight,
wrapperClone,
nodeParent;
do {
if (goDeeper && node.nodeType === NODE_TYPE.TEXT_NODE) {
if (IGNORE_TAGS.indexOf(node.parentNode.tagName) === -1 && node.nodeValue.trim() !== '') {
wrapperClone = wrapper.cloneNode(true);
wrapperClone.setAttribute(DATA_ATTR, true);
nodeParent = node.parentNode;
// highlight if a node is inside the el
if (dom(this.el).contains(nodeParent) || nodeParent === this.el) {
highlight = dom(node).wrap(wrapperClone);
highlights.push(highlight);
}
}
goDeeper = false;
}
if (node === endContainer && !(endContainer.hasChildNodes() && goDeeper)) {
done = true;
}
if (node.tagName && IGNORE_TAGS.indexOf(node.tagName) > -1) {
if (endContainer.parentNode === node) {
done = true;
}
goDeeper = false;
}
if (goDeeper && node.hasChildNodes()) {
node = node.firstChild;
} else if (node.nextSibling) {
node = node.nextSibling;
goDeeper = true;
} else {
node = node.parentNode;
goDeeper = false;
}
} while (!done);
return highlights;
};
/**
* Normalizes highlights. Ensures that highlighting is done with use of the smallest possible number of
* wrapping HTML elements.
* Flattens highlights structure and merges sibling highlights. Normalizes text nodes within highlights.
* @param {Array} highlights - highlights to normalize.
* @returns {Array} - array of normalized highlights. Order and number of returned highlights may be different than
* input highlights.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.normalizeHighlights = function (highlights) {
var normalizedHighlights;
this.flattenNestedHighlights(highlights);
this.mergeSiblingHighlights(highlights);
// omit removed nodes
normalizedHighlights = highlights.filter(function (hl) {
return hl.parentElement ? hl : null;
});
normalizedHighlights = unique(normalizedHighlights);
normalizedHighlights.sort(function (a, b) {
return a.offsetTop - b.offsetTop || a.offsetLeft - b.offsetLeft;
});
return normalizedHighlights;
};
/**
* Flattens highlights structure.
* Note: this method changes input highlights - their order and number after calling this method may change.
* @param {Array} highlights - highlights to flatten.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.flattenNestedHighlights = function (highlights) {
var again,
self = this;
sortByDepth(highlights, true);
function flattenOnce() {
var again = false;
highlights.forEach(function (hl, i) {
var parent = hl.parentElement,
parentPrev = parent.previousSibling,
parentNext = parent.nextSibling;
if (self.isHighlight(parent)) {
if (!haveSameColor(parent, hl)) {
if (!hl.nextSibling) {
dom(hl).insertBefore(parentNext || parent);
again = true;
}
if (!hl.previousSibling) {
dom(hl).insertAfter(parentPrev || parent);
again = true;
}
if (!parent.hasChildNodes()) {
dom(parent).remove();
}
} else {
parent.replaceChild(hl.firstChild, hl);
highlights[i] = parent;
again = true;
}
}
});
return again;
}
do {
again = flattenOnce();
} while (again);
};
/**
* Merges sibling highlights and normalizes descendant text nodes.
* Note: this method changes input highlights - their order and number after calling this method may change.
* @param highlights
* @memberof TextHighlighter
*/
TextHighlighter.prototype.mergeSiblingHighlights = function (highlights) {
var self = this;
function shouldMerge(current, node) {
return node && node.nodeType === NODE_TYPE.ELEMENT_NODE &&
haveSameColor(current, node) &&
self.isHighlight(node);
}
highlights.forEach(function (highlight) {
var prev = highlight.previousSibling,
next = highlight.nextSibling;
if (shouldMerge(highlight, prev)) {
dom(highlight).prepend(prev.childNodes);
dom(prev).remove();
}
if (shouldMerge(highlight, next)) {
dom(highlight).append(next.childNodes);
dom(next).remove();
}
dom(highlight).normalizeTextNodes();
});
};
/**
* Sets highlighting color.
* @param {string} color - valid CSS color.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.setColor = function (color) {
this.options.color = color;
};
/**
* Returns highlighting color.
* @returns {string}
* @memberof TextHighlighter
*/
TextHighlighter.prototype.getColor = function () {
return this.options.color;
};
/**
* Removes highlights from element. If element is a highlight itself, it is removed as well.
* If no element is given, all highlights all removed.
* @param {HTMLElement} [element] - element to remove highlights from
* @memberof TextHighlighter
*/
TextHighlighter.prototype.removeHighlights = function (element) {
var container = element || this.el,
highlights = this.getHighlights({ container: container }),
self = this;
function mergeSiblingTextNodes(textNode) {
var prev = textNode.previousSibling,
next = textNode.nextSibling;
if (prev && prev.nodeType === NODE_TYPE.TEXT_NODE) {
textNode.nodeValue = prev.nodeValue + textNode.nodeValue;
dom(prev).remove();
}
if (next && next.nodeType === NODE_TYPE.TEXT_NODE) {
textNode.nodeValue = textNode.nodeValue + next.nodeValue;
dom(next).remove();
}
}
function removeHighlight(highlight) {
var textNodes = dom(highlight).unwrap();
textNodes.forEach(function (node) {
mergeSiblingTextNodes(node);
});
}
sortByDepth(highlights, true);
highlights.forEach(function (hl) {
if (self.options.onRemoveHighlight(hl) === true) {
removeHighlight(hl);
}
});
};
/**
* Returns highlights from given container.
* @param params
* @param {HTMLElement} [params.container] - return highlights from this element. Default: the element the
* highlighter is applied to.
* @param {boolean} [params.andSelf] - if set to true and container is a highlight itself, add container to
* returned results. Default: true.
* @param {boolean} [params.grouped] - if set to true, highlights are grouped in logical groups of highlights added
* in the same moment. Each group is an object which has got array of highlights, 'toString' method and 'timestamp'
* property. Default: false.
* @returns {Array} - array of highlights.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.getHighlights = function (params) {
params = defaults(params, {
container: this.el,
andSelf: true,
grouped: false
});
var nodeList = params.container.querySelectorAll('[' + DATA_ATTR + ']'),
highlights = Array.prototype.slice.call(nodeList);
if (params.andSelf === true && params.container.hasAttribute(DATA_ATTR)) {
highlights.push(params.container);
}
if (params.grouped) {
highlights = groupHighlights(highlights);
}
return highlights;
};
/**
* Returns true if element is a highlight.
* All highlights have 'data-highlighted' attribute.
* @param el - element to check.
* @returns {boolean}
* @memberof TextHighlighter
*/
TextHighlighter.prototype.isHighlight = function (el) {
return el && el.nodeType === NODE_TYPE.ELEMENT_NODE && el.hasAttribute(DATA_ATTR);
};
/**
* Serializes all highlights in the element the highlighter is applied to.
* @returns {string} - stringified JSON with highlights definition
* @memberof TextHighlighter
*/
TextHighlighter.prototype.serializeHighlights = function () {
var highlights = this.getHighlights(),
refEl = this.el,
hlDescriptors = [];
function getElementPath(el, refElement) {
var path = [],
childNodes;
do {
childNodes = Array.prototype.slice.call(el.parentNode.childNodes);
path.unshift(childNodes.indexOf(el));
el = el.parentNode;
} while (el !== refElement || !el);
return path;
}
sortByDepth(highlights, false);
highlights.forEach(function (highlight) {
var offset = 0, // Hl offset from previous sibling within parent node.
length = highlight.textContent.length,
hlPath = getElementPath(highlight, refEl),
wrapper = highlight.cloneNode(true);
wrapper.innerHTML = '';
wrapper = wrapper.outerHTML;
if (highlight.previousSibling && highlight.previousSibling.nodeType === NODE_TYPE.TEXT_NODE) {
offset = highlight.previousSibling.length;
}
hlDescriptors.push([
wrapper,
highlight.textContent,
hlPath.join(':'),
offset,
length
]);
});
return JSON.stringify(hlDescriptors);
};
/**
* Deserializes highlights.
* @throws exception when can't parse JSON or JSON has invalid structure.
* @param {object} json - JSON object with highlights definition.
* @returns {Array} - array of deserialized highlights.
* @memberof TextHighlighter
*/
TextHighlighter.prototype.deserializeHighlights = function (json) {
var hlDescriptors,
highlights = [],
self = this;
if (!json) {
return highlights;
}
try {
hlDescriptors = JSON.parse(json);
} catch (e) {
throw "Can't parse JSON: " + e;
}
function deserializationFn(hlDescriptor) {
var hl = {
wrapper: hlDescriptor[0],
text: hlDescriptor[1],
path: hlDescriptor[2].split(':'),
offset: hlDescriptor[3],
length: hlDescriptor[4]
},
elIndex = hl.path.pop(),
node = self.el,
hlNode,
highlight,
idx;
while (!!(idx = hl.path.shift())) {
node = node.childNodes[idx];
}
if (node.childNodes[elIndex-1] && node.childNodes[elIndex-1].nodeType === NODE_TYPE.TEXT_NODE) {
elIndex -= 1;
}
node = node.childNodes[elIndex];
hlNode = node.splitText(hl.offset);
hlNode.splitText(hl.length);
if (hlNode.nextSibling && !hlNode.nextSibling.nodeValue) {
dom(hlNode.nextSibling).remove();
}
if (hlNode.previousSibling && !hlNode.previousSibling.nodeValue) {
dom(hlNode.previousSibling).remove();
}
highlight = dom(hlNode).wrap(dom().fromHTML(hl.wrapper)[0]);
highlights.push(highlight);
}
hlDescriptors.forEach(function (hlDescriptor) {
try {
deserializationFn(hlDescriptor);
} catch (e) {
if (console && console.warn) {
console.warn("Can't deserialize highlight descriptor. Cause: " + e);
}
}
});
return highlights;
};
/**
* Finds and highlights given text.
* @param {string} text - text to search for
* @param {boolean} [caseSensitive] - if set to true, performs case sensitive search (default: true)
* @memberof TextHighlighter
*/
TextHighlighter.prototype.find = function (text, caseSensitive) {
var wnd = dom(this.el).getWindow(),
scrollX = wnd.scrollX,
scrollY = wnd.scrollY,
caseSens = (typeof caseSensitive === 'undefined' ? true : caseSensitive);
dom(this.el).removeAllRanges();
if (wnd.find) {
while (wnd.find(text, caseSens)) {
this.doHighlight(true);
}
} else if (wnd.document.body.createTextRange) {
var textRange = wnd.document.body.createTextRange();
textRange.moveToElementText(this.el);
while (textRange.findText(text, 1, caseSens ? 4 : 0)) {
if (!dom(this.el).contains(textRange.parentElement()) && textRange.parentElement() !== this.el) {
break;
}
textRange.select();
this.doHighlight(true);
textRange.collapse(false);
}
}
dom(this.el).removeAllRanges();
wnd.scrollTo(scrollX, scrollY);
};
/**
* Creates wrapper for highlights.
* TextHighlighter instance calls this method each time it needs to create highlights and pass options retrieved
* in constructor.
* @param {object} options - the same object as in TextHighlighter constructor.
* @returns {HTMLElement}
* @memberof TextHighlighter
* @static
*/
TextHighlighter.createWrapper = function (options) {
var span = document.createElement('span');
span.style.backgroundColor = options.color;
span.className = options.highlightedClass;
return span;
};
global.TextHighlighter = TextHighlighter;
})(window);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment