Created
June 18, 2015 11:20
-
-
Save dilkhush/5933ec99adf8cf28ec08 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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