import { refineRangeBoundaries, retrieveHighlights, isElementHighlight, sortByDepth, haveSameColor, createWrapper, } from "../utils/highlights"; import dom, { NODE_TYPE } from "../utils/dom"; import { IGNORE_TAGS, DATA_ATTR, TIMESTAMP_ATTR } from "../config"; import { unique } from "../utils/arrays"; /** * PrimitivoHighlighter that provides text highlighting functionality to dom elements * for simple use cases. * * @callback onAfterHighlightCallbackV1 * @param {Range} range * @param {HTMLElement[]} highlights * @param {number} timestamp */ class PrimitivoHighlighter { /** * Creates a PrimitivoHighlighter instance for functionality specific to the original implementation. * * @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 {onAfterHighlightCallbackV1} options.onAfterHighlight - function called after highlight is created. Array of created * wrappers is passed as param. * @class PrimitivoHighlighter */ constructor(element, options) { this.el = element; this.options = options; } /** * Highlights range. * Wraps text of given range object in wrapper element. * @param {Range} range * @param {HTMLElement} wrapper * @returns {Array} - array of created highlights. * @memberof PrimitivoHighlighter */ highlightRange(range, wrapper) { if (!range || range.collapsed) { return []; } let 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 PrimitivoHighlighter */ normalizeHighlights(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 PrimitivoHighlighter */ flattenNestedHighlights(highlights) { let again, self = this; sortByDepth(highlights, true); function flattenOnce() { let again = false; highlights.forEach(function(hl, i) { let parent = hl.parentElement, parentPrev = parent.previousSibling, parentNext = parent.nextSibling; if (self.isHighlight(parent, DATA_ATTR)) { if (!haveSameColor(parent, hl)) { if (!hl.nextSibling) { if (!parentNext) { dom(hl).insertAfter(parent); } else { dom(hl).insertBefore(parentNext); } dom(hl).insertBefore(parentNext || parent); again = true; } if (!hl.previousSibling) { if (!parentPrev) { dom(hl).insertBefore(parent); } else { dom(hl).insertAfter(parentPrev); } dom(hl).insertAfter(parentPrev || parent); again = true; } if ( hl.previousSibling && hl.previousSibling.nodeType == 3 && hl.nextSibling && hl.nextSibling.nodeType == 3 ) { let spanleft = document.createElement("span"); spanleft.style.backgroundColor = parent.style.backgroundColor; spanleft.className = parent.className; let timestamp = parent.attributes[TIMESTAMP_ATTR].nodeValue; spanleft.setAttribute(TIMESTAMP_ATTR, timestamp); spanleft.setAttribute(DATA_ATTR, true); let spanright = spanleft.cloneNode(true); dom(hl.previousSibling).wrap(spanleft); dom(hl.nextSibling).wrap(spanright); let nodes = Array.prototype.slice.call(parent.childNodes); nodes.forEach(function(node) { dom(node).insertBefore(node.parentNode); }); 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 PrimitivoHighlighter */ mergeSiblingHighlights(highlights) { var self = this; function shouldMerge(current, node) { return ( node && node.nodeType === NODE_TYPE.ELEMENT_NODE && haveSameColor(current, node) && self.isHighlight(node, DATA_ATTR) ); } 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(); }); } /** * Highlights current range. * @param {boolean} keepRange - Don't remove range after highlighting. Default: false. * @memberof PrimitivoHighlighter */ doHighlight(keepRange) { let range = dom(this.el).getRange(), wrapper, createdHighlights, normalizedHighlights, timestamp; if (!range || range.collapsed) { return; } if (this.options.onBeforeHighlight(range) === true) { timestamp = +new Date(); wrapper = 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(); } } /** * 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 PrimitivoHighlighter */ removeHighlights(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 PrimitivoHighlighter */ getHighlights(params) { const mergedParams = { container: this.el, dataAttr: DATA_ATTR, timestampAttr: TIMESTAMP_ATTR, ...params, }; return retrieveHighlights(mergedParams); } /** * Returns true if element is a highlight. * * @param el - element to check. * @returns {boolean} * @memberof PrimitivoHighlighter */ isHighlight(el, dataAttr) { return isElementHighlight(el, dataAttr); } /** * Serializes all highlights in the element the highlighter is applied to. * @returns {string} - stringified JSON with highlights definition * @memberof PrimitivoHighlighter */ serializeHighlights() { let highlights = this.getHighlights(), refEl = this.el, hlDescriptors = []; function getElementPath(el, refElement) { let 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) { let 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 PrimitivoHighlighter */ deserializeHighlights(json) { let hlDescriptors, highlights = [], self = this; if (!json) { return highlights; } try { hlDescriptors = JSON.parse(json); } catch (e) { throw "Can't parse JSON: " + e; } function deserializationFn(hlDescriptor) { let 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; } } export default PrimitivoHighlighter;