UNPKG

pdfmark.js

Version:

Highlight keywords in pdfjs svg output. Based on mark.js.

1,137 lines (1,072 loc) 36.3 kB
import DOMIterator from './domiterator'; import RegExpCreator from './regexpcreator'; const setAttributes = (el, attrs) => { for (let key in attrs) { el.setAttribute(key, attrs[key]); } }; /** * Marks search terms in DOM elements * @example * new Mark(document.querySelector('.context')).mark('lorem ipsum'); * @example * new Mark(document.querySelector('.context')).markRegExp(/lorem/gmi); * @example * new Mark('.context').markRanges([{start:10,length:0}]); */ class Mark { /** * @param {HTMLElement|HTMLElement[]|NodeList|string} ctx - The context DOM * element, an array of DOM elements, a NodeList or a selector */ constructor(ctx) { /** * The context of the instance. Either a DOM element, an array of DOM * elements, a NodeList or a selector * @type {HTMLElement|HTMLElement[]|NodeList|string} * @access protected */ this.ctx = ctx; /** * Specifies if the current browser is a IE (necessary for the node * normalization bug workaround). See {@link Mark#unhighlightMatches} * @type {boolean} * @access protected */ this.ie = false; const ua = window.navigator.userAgent; if (ua.indexOf('MSIE') > -1 || ua.indexOf('Trident') > -1) { this.ie = true; } } /** * Options defined by the user. They will be initialized from one of the * public methods. See {@link Mark#mark}, {@link Mark#markRegExp}, * {@link Mark#markRanges} and {@link Mark#unmark} for option properties. * @type {object} * @param {object} [val] - An object that will be merged with defaults * @access protected */ set opt(val) { this._opt = Object.assign( {}, { element: '', className: '', exclude: [], iframes: false, iframesTimeout: 5000, separateWordSearch: true, acrossElements: false, ignoreGroups: 0, each: () => {}, noMatch: () => {}, filter: () => true, done: () => {}, debug: false, log: window.console }, val ); } get opt() { return this._opt; } /** * An instance of DOMIterator * @type {DOMIterator} * @access protected */ get iterator() { // always return new instance in case there were option changes return new DOMIterator( this.ctx, this.opt.iframes, this.opt.exclude, this.opt.iframesTimeout ); } /** * Logs a message if log is enabled * @param {string} msg - The message to log * @param {string} [level="debug"] - The log level, e.g. <code>warn</code> * <code>error</code>, <code>debug</code> * @access protected */ log(msg, level = 'debug') { const log = this.opt.log; if (!this.opt.debug) { return; } if (typeof log === 'object' && typeof log[level] === 'function') { log[level](`mark.js: ${msg}`); } } /** * @typedef Mark~separatedKeywords * @type {object.<string>} * @property {array.<string>} keywords - The list of keywords * @property {number} length - The length */ /** * Returns a list of keywords dependent on whether separate word search * was defined. Also it filters empty keywords * @param {array} sv - The array of keywords * @return {Mark~separatedKeywords} * @access protected */ getSeparatedKeywords(sv) { let stack = []; sv.forEach(kw => { if (!this.opt.separateWordSearch) { if (kw.trim() && stack.indexOf(kw) === -1) { stack.push(kw); } } else { kw.split(' ').forEach(kwSplitted => { if (kwSplitted.trim() && stack.indexOf(kwSplitted) === -1) { stack.push(kwSplitted); } }); } }); return { // sort because of https://git.io/v6USg keywords: stack.sort((a, b) => { return b.length - a.length; }), length: stack.length }; } /** * Check if a value is a number * @param {number|string} value - the value to check; * numeric strings allowed * @return {boolean} * @access protected */ isNumeric(value) { // http://stackoverflow.com/a/16655847/145346 // eslint-disable-next-line eqeqeq return Number(parseFloat(value)) == value; } /** * @typedef Mark~rangeObject * @type {object} * @property {number} start - The start position within the composite value * @property {number} length - The length of the string to mark within the * composite value. */ /** * @typedef Mark~setOfRanges * @type {object[]} * @property {Mark~rangeObject} */ /** * Returns a processed list of integer offset indexes that do not overlap * each other, and remove any string values or additional elements * @param {Mark~setOfRanges} array - unprocessed raw array * @return {Mark~setOfRanges} - processed array with any invalid entries * removed * @throws Will throw an error if an array of objects is not passed * @access protected */ checkRanges(array) { // start and length indexes are included in an array of objects // [{start: 0, length: 1}, {start: 4, length: 5}] // quick validity check of the first entry only if ( !Array.isArray(array) || Object.prototype.toString.call(array[0]) !== '[object Object]' ) { this.log('markRanges() will only accept an array of objects'); this.opt.noMatch(array); return []; } const stack = []; let last = 0; array // ensure there is no overlap in start & end offsets .sort((a, b) => { return a.start - b.start; }) .forEach(item => { let { start, end, valid } = this.callNoMatchOnInvalidRanges(item, last); if (valid) { // preserve item in case there are extra key:values within item.start = start; item.length = end - start; stack.push(item); last = end; } }); return stack; } /** * @typedef Mark~validObject * @type {object} * @property {number} start - The start position within the composite value * @property {number} end - The calculated end position within the composite * value. * @property {boolean} valid - boolean value indicating that the start and * calculated end range is valid */ /** * Initial validation of ranges for markRanges. Preliminary checks are done * to ensure the start and length values exist and are not zero or non- * numeric * @param {Mark~rangeObject} range - the current range object * @param {number} last - last index of range * @return {Mark~validObject} * @access protected */ callNoMatchOnInvalidRanges(range, last) { let start; let end; let valid = false; if (range && typeof range.start !== 'undefined') { start = parseInt(range.start, 10); end = start + parseInt(range.length, 10); // ignore overlapping values & non-numeric entries if ( this.isNumeric(range.start) && this.isNumeric(range.length) && end - last > 0 && end - start > 0 ) { valid = true; } else { this.log( 'Ignoring invalid or overlapping range: ' + `${JSON.stringify(range)}` ); this.opt.noMatch(range); } } else { this.log(`Ignoring invalid range: ${JSON.stringify(range)}`); this.opt.noMatch(range); } return { start: start, end: end, valid: valid }; } /** * Check valid range for markRanges. Check ranges with access to the context * string. Range values are double checked, lengths that extend the mark * beyond the string length are limitied and ranges containing only * whitespace are ignored * @param {Mark~rangeObject} range - the current range object * @param {number} originalLength - original length of the context string * @param {string} string - current content string * @return {Mark~validObject} * @access protected */ checkWhitespaceRanges(range, originalLength, string) { let end; let valid = true; // the max value changes after the DOM is manipulated let max = string.length; // adjust offset to account for highlighted text node let offset = originalLength - max; let start = parseInt(range.start, 10) - offset; // make sure to stop at max start = start > max ? max : start; end = start + parseInt(range.length, 10); if (end > max) { end = max; this.log(`End range automatically set to the max value of ${max}`); } if (start < 0 || end - start < 0 || start > max || end > max) { valid = false; this.log(`Invalid range: ${JSON.stringify(range)}`); this.opt.noMatch(range); } else if (string.substring(start, end).replace(/\s+/g, '') === '') { valid = false; // whitespace only; even if highlighted it is not visible this.log('Skipping whitespace only range: ' + JSON.stringify(range)); this.opt.noMatch(range); } return { start: start, end: end, valid: valid }; } /** * @typedef Mark~getTextNodesDict * @type {object.<string>} * @property {string} value - The composite value of all text nodes * @property {object[]} nodes - An array of objects * @property {number} nodes.start - The start position within the composite * value * @property {number} nodes.end - The end position within the composite * value * @property {HTMLElement} nodes.node - The DOM text node element */ /** * Callback * @callback Mark~getTextNodesCallback * @param {Mark~getTextNodesDict} */ /** * Calls the callback with an object containing all text nodes (including * iframe text nodes) with start and end positions and the composite value * of them (string) * @param {Mark~getTextNodesCallback} cb - Callback * @access protected */ getTextNodes(cb) { let val = ''; let nodes = []; this.iterator.forEachNode( NodeFilter.SHOW_TEXT, node => { nodes.push({ start: val.length, end: (val += node.textContent).length, node }); }, node => { if (this.matchesExclude(node.parentNode)) { return NodeFilter.FILTER_REJECT; } else { return NodeFilter.FILTER_ACCEPT; } }, () => { cb({ value: val, nodes: nodes }); } ); } /** * Checks if an element matches any of the specified exclude selectors. Also * it checks for elements in which no marks should be performed (e.g. * script and style tags) and optionally already marked elements * @param {HTMLElement} el - The element to check * @return {boolean} * @access protected */ matchesExclude(el) { return DOMIterator.matches( el, this.opt.exclude.concat([ // ignores the elements itself, not their childrens (selector *) 'script', 'style', 'title', 'head', 'html' ]) ); } /** * Wraps the instance element and class around matches that fit the start and * end positions within the node * @param {HTMLElement} node - The DOM text node * @param {number} start - The position where to start highlighting * @param {number} end - The position where to end highlighting * @return {HTMLElement} Returns the split text node that will appear * after the wrapped text node * @access protected */ wrapInHtmlTag(node, start, end) { const hEl = !this.opt.element ? 'mark' : this.opt.element; const startNode = node.splitText(start); const ret = startNode.splitText(end - start); let repl = document.createElement(hEl); repl.setAttribute('data-markjs', 'true'); if (this.opt.className) { repl.setAttribute('class', this.opt.className); } repl.textContent = startNode.textContent; startNode.parentNode.replaceChild(repl, startNode); return ret; } /** * Recursive function to get the length of all the text of * previous siblings * @param {HTMLElement} node - The DOM text node * @param {number} existingOffset - accumulated length of all previously * checked text nodes * @return {number} accumulated length * @access protected */ getTextNodeOffset(node, exisitingOffset) { return node ? this.getTextNodeOffset( node.previousSibling, node.length + exisitingOffset ) : exisitingOffset; } /** * Highlights the matches that fit the start and * end positions within the node by adding an svg rectangle * behind the text * @param {HTMLElement} node - The DOM text node * @param {number} start - The position where to start highlighting * @param {number} end - The position where to end highlighting * @return {HTMLElement} Returns the split text node that will appear * after the highlighted text node * @access protected */ addSvgRectangle(node, start, end) { // these nodes are specific to pdfjs' svg output where all text nodes are wrapped: // <svg:g><svg:text><svg:tspan>{text}</svg:tspan></svg:text></svg:g> const tspan = node.parentNode; const text = node.parentNode.parentNode; const g = node.parentNode.parentNode.parentNode; // the x attribute is a set of x positions for every single // letter of a text block. We can use this to determine start // and end position of our highlight boxes const letterStartPositions = tspan.getAttribute('x').split(' '); // if we've already had a match in the same text block, the text // nodes are already split (see below). To look up the letterPositions // we need to know the full offset of out start and end positions const textNodeOffset = this.getTextNodeOffset(node.previousSibling, 0); const startWithOffset = start + textNodeOffset; const endWithOffset = end + textNodeOffset; const rectangle = document.createElementNS( 'http://www.w3.org/2000/svg', 'rect' ); setAttributes(rectangle, { x: `${letterStartPositions[startWithOffset]}px`, y: `${tspan.getAttribute('y') - parseInt(tspan.getAttribute('font-size'))}`, width: `${tspan.getEndPositionOfChar(endWithOffset - 1).x - parseFloat(letterStartPositions[startWithOffset])}px`, height: tspan.getAttribute('font-size'), fill: 'yellow', transform: text.getAttribute('transform'), 'data-markjs': 'true' }); g.insertBefore(rectangle, text); const blackTextOnTop = tspan.cloneNode(true); setAttributes(blackTextOnTop, { x: letterStartPositions.slice(startWithOffset, endWithOffset).join(' '), fill: 'black', 'data-markjs': 'true' }); blackTextOnTop.textContent = tspan.textContent.slice( startWithOffset, endWithOffset ); text.insertBefore(blackTextOnTop, tspan.nextSibling); // the library usually splits the text and adds <mark> tags around matches. // Remove the splitting would break the search functionality. const startNode = node.splitText(start); const ret = startNode.splitText(end - start); return ret; } /** * Determines if we're in regular HTML or an svg and calls the right * highlighting function accordingly * @param {HTMLElement} node - The DOM text node * @param {number} start - The position where to start highlighting * @param {number} end - The position where to end highlighting * @return {HTMLElement} Returns the split text node that will appear * after the highlighted text node * @access protected */ highlightRangeInTextNode(node, start, end) { const isPdfjsSvgOutput = node.parentNode.nodeName === 'svg:tspan'; return isPdfjsSvgOutput ? this.addSvgRectangle(node, start, end) : this.wrapInHtmlTag(node, start, end); } /** * @typedef Mark~highlightRangeInMappedTextNodeDict * @type {object.<string>} * @property {string} value - The composite value of all text nodes * @property {object[]} nodes - An array of objects * @property {number} nodes.start - The start position within the composite * value * @property {number} nodes.end - The end position within the composite * value * @property {HTMLElement} nodes.node - The DOM text node element */ /** * Each callback * @callback Mark~highlightMatchesEachCallback * @param {HTMLElement} node - The highlighted DOM element * @param {number} lastIndex - The last matching position within the * composite value of text nodes */ /** * Filter callback * @callback Mark~highlightMatchesFilterCallback * @param {HTMLElement} node - The matching text node DOM element */ /** * Determines matches by start and end positions using the text node * dictionary even across text nodes and calls * {@link Mark#highlightRangeInTextNode} to highlight them * @param {Mark~highlightRangeInMappedTextNodeDict} dict - The dictionary * @param {number} start - The start position of the match * @param {number} end - The end position of the match * @param {Mark~highlightMatchesFilterCallback} filterCb - Filter callback * @param {Mark~highlightMatchesEachCallback} eachCb - Each callback * @access protected */ highlightRangeInMappedTextNode(dict, start, end, filterCb, eachCb) { // iterate over all text nodes to find the one matching the positions dict.nodes.every((n, i) => { const sibl = dict.nodes[i + 1]; if (typeof sibl === 'undefined' || sibl.start > start) { if (!filterCb(n.node)) { return false; } // map range from dict.value to text node const s = start - n.start; const e = (end > n.end ? n.end : end) - n.start; const startStr = dict.value.substr(0, n.start); const endStr = dict.value.substr(e + n.start); n.node = this.highlightRangeInTextNode(n.node, s, e); // recalculate positions to also find subsequent matches in the // same text node. Necessary as the text node in dict now only // contains the splitted part after the highlighted one dict.value = startStr + endStr; dict.nodes.forEach((k, j) => { if (j >= i) { if (dict.nodes[j].start > 0 && j !== i) { dict.nodes[j].start -= e; } dict.nodes[j].end -= e; } }); end -= e; eachCb(n.node.previousSibling, n.start); if (end > n.end) { start = n.end; } else { return false; } } return true; }); } /** * @param {HTMLElement} node - The text node where the match occurs * @param {number} pos - The current position of the match within the node * @param {number} len - The length of the current match within the node * @param {Mark~highlightMatchesEachCallback} eachCb */ highlightGroups(node, pos, len, eachCb) { node = this.highlightRangeInTextNode(node, pos, pos + len); eachCb(node.previousSibling); return node; } /** * Separate groups * @param {HTMLElement} node - The text node where the match occurs * @param {array} match - The current match * @param {number} matchIdx - The start of the match based on ignoreGroups * @param {Mark~highlightMatchesFilterCallback} filterCb * @param {Mark~highlightMatchesEachCallback} eachCb */ separateGroups(node, match, matchIdx, filterCb, eachCb) { let matchLen = match.length; for (let i = 1; i < matchLen; i++) { let pos = node.textContent.indexOf(match[i]); if (match[i] && pos > -1 && filterCb(match[i], node)) { node = this.highlightGroups(node, pos, match[i].length, eachCb); } } return node; } /** * Filter callback before each highlighting * @callback Mark~highlightMatchesFilterCallback * @param {string} match - The matching string * @param {HTMLElement} node - The text node where the match occurs */ /** * Callback for each highlighted element * @callback Mark~highlightMatchesEachCallback * @param {HTMLElement} element - The marked DOM element */ /** * Callback on end * @callback Mark~highlightMatchesEndCallback */ /** * Wraps the instance element and class around matches within single HTML * elements in all contexts * @param {RegExp} regex - The regular expression to be searched for * @param {number} ignoreGroups - A number indicating the amount of RegExp * matching groups to ignore * @param {Mark~highlightMatchesFilterCallback} filterCb * @param {Mark~highlightMatchesEachCallback} eachCb * @param {Mark~highlightMatchesEndCallback} endCb * @access protected */ highlightMatches(regex, ignoreGroups, filterCb, eachCb, endCb) { const matchIdx = ignoreGroups === 0 ? 0 : ignoreGroups + 1; this.getTextNodes(dict => { dict.nodes.forEach(node => { node = node.node; let match; while ( (match = regex.exec(node.textContent)) !== null && match[matchIdx] !== '' ) { if (this.opt.separateGroups) { node = this.separateGroups(node, match, matchIdx, filterCb, eachCb); } else { if (!filterCb(match[matchIdx], node)) { continue; } let pos = match.index; if (matchIdx !== 0) { for (let i = 1; i < matchIdx; i++) { pos += match[i].length; } } node = this.highlightGroups( node, pos, match[matchIdx].length, eachCb ); } // reset index of last match as the node changed and the // index isn't valid anymore http://tinyurl.com/htsudjd regex.lastIndex = 0; } }); endCb(); }); } /** * Callback for each highlighted element * @callback Mark~highlightMatchesAcrossElementsEachCallback * @param {HTMLElement} element - The marked DOM element */ /** * Filter callback before each highlighting * @callback Mark~highlightMatchesAcrossElementsFilterCallback * @param {string} match - The matching string * @param {HTMLElement} node - The text node where the match occurs */ /** * Callback on end * @callback Mark~highlightMatchesAcrossElementsEndCallback */ /** * Wraps the instance element and class around matches across all HTML * elements in all contexts * @param {RegExp} regex - The regular expression to be searched for * @param {number} ignoreGroups - A number indicating the amount of RegExp * matching groups to ignore * @param {Mark~highlightMatchesAcrossElementsFilterCallback} filterCb * @param {Mark~highlightMatchesAcrossElementsEachCallback} eachCb * @param {Mark~highlightMatchesAcrossElementsEndCallback} endCb * @access protected */ highlightMatchesAcrossElements(regex, ignoreGroups, filterCb, eachCb, endCb) { const matchIdx = ignoreGroups === 0 ? 0 : ignoreGroups + 1; this.getTextNodes(dict => { let match; while ( (match = regex.exec(dict.value)) !== null && match[matchIdx] !== '' ) { // calculate range inside dict.value let start = match.index; if (matchIdx !== 0) { for (let i = 1; i < matchIdx; i++) { start += match[i].length; } } const end = start + match[matchIdx].length; // note that dict will be updated automatically, as it'll change // in the highlighting process, due to the fact that text // nodes will be splitted this.highlightRangeInMappedTextNode( dict, start, end, node => { return filterCb(match[matchIdx], node); }, (node, lastIndex) => { regex.lastIndex = lastIndex; eachCb(node); } ); } endCb(); }); } /** * Callback for each highlighted element * @callback Mark~highlightRangeFromIndexEachCallback * @param {HTMLElement} element - The marked DOM element * @param {Mark~rangeObject} range - the current range object; provided * start and length values will be numeric integers modified from the * provided original ranges. */ /** * Filter callback before each highlighting * @callback Mark~highlightRangeFromIndexFilterCallback * @param {HTMLElement} node - The text node which includes the range * @param {Mark~rangeObject} range - the current range object * @param {string} match - string extracted from the matching range * @param {number} counter - A counter indicating the number of all marks */ /** * Callback on end * @callback Mark~highlightRangeFromIndexEndCallback */ /** * Wraps the indicated ranges across all HTML elements in all contexts * @param {Mark~setOfRanges} ranges * @param {Mark~highlightRangeFromIndexFilterCallback} filterCb * @param {Mark~highlightRangeFromIndexEachCallback} eachCb * @param {Mark~highlightRangeFromIndexEndCallback} endCb * @access protected */ highlightRangeFromIndex(ranges, filterCb, eachCb, endCb) { this.getTextNodes(dict => { const originalLength = dict.value.length; ranges.forEach((range, counter) => { let { start, end, valid } = this.checkWhitespaceRanges( range, originalLength, dict.value ); if (valid) { this.highlightRangeInMappedTextNode( dict, start, end, node => { return filterCb( node, range, dict.value.substring(start, end), counter ); }, node => { eachCb(node, range); } ); } }); endCb(); }); } /** * Unhighlights the specified DOM node with its content (text nodes or HTML) * without destroying possibly present events (using innerHTML) and normalizes * the parent at the end (merge splitted text nodes) * @param {HTMLElement} node - The DOM node to unhighlight * @access protected */ unhighlightMatches(node) { const parent = node.parentNode; let docFrag = document.createDocumentFragment(); while (node.firstChild) { if (parent.nodeName === 'svg:text') { node.removeChild(node.firstChild); } else { docFrag.appendChild(node.removeChild(node.firstChild)); } } parent.replaceChild(docFrag, node); if (!this.ie) { // use browser's normalize method parent.normalize(); } else { // custom method (needs more time) this.normalizeTextNode(parent); } } /** * Normalizes text nodes. It's a workaround for the native normalize method * that has a bug in IE (see attached link). Should only be used in IE * browsers as it's slower than the native method. * @see {@link http://tinyurl.com/z5asa8c} * @param {HTMLElement} node - The DOM node to normalize * @access protected */ normalizeTextNode(node) { if (!node) { return; } if (node.nodeType === 3) { while (node.nextSibling && node.nextSibling.nodeType === 3) { node.nodeValue += node.nextSibling.nodeValue; node.parentNode.removeChild(node.nextSibling); } } else { this.normalizeTextNode(node.firstChild); } this.normalizeTextNode(node.nextSibling); } /** * Callback for each marked element * @callback Mark~markEachCallback * @param {HTMLElement} element - The marked DOM element */ /** * Callback if there were no matches * @callback Mark~markNoMatchCallback * @param {RegExp} term - The search term that was not found */ /** * Callback when finished * @callback Mark~commonDoneCallback * @param {number} totalMatches - The number of marked elements */ /** * @typedef Mark~commonOptions * @type {object.<string>} * @property {string} [element="mark"] - HTML element tag name * @property {string} [className] - An optional class name * @property {string[]} [exclude] - An array with exclusion selectors. * Elements matching those selectors will be ignored * @property {boolean} [iframes=false] - Whether to search inside iframes * @property {number} [iframesTimeout=5000] - Maximum ms to wait for a load * event of an iframe * @property {boolean} [acrossElements=false] - Whether to find matches * across HTML elements. By default, only matches within single HTML * elements will be found * @property {Mark~markEachCallback} [each] * @property {Mark~markNoMatchCallback} [noMatch] * @property {Mark~commonDoneCallback} [done] * @property {boolean} [debug=false] - Whether to log messages * @property {object} [log=window.console] - Where to log messages (only if * debug is true) */ /** * Callback if there were no matches * @callback Mark~markRegExpNoMatchCallback * @param {RegExp} regexp - The regular expression */ /** * Callback to filter matches * @callback Mark~markRegExpFilterCallback * @param {HTMLElement} textNode - The text node which includes the match * @param {string} match - The matching string for the RegExp * @param {number} counter - A counter indicating the number of all marks */ /** * These options also include the common options from * {@link Mark~commonOptions} * @typedef Mark~markRegExpOptions * @type {object.<string>} * @property {number} [ignoreGroups=0] - A number indicating the amount of * RegExp matching groups to ignore * @property {boolean} [separateGroups] - Whether to mark each regular * expression group as a separate match * @property {Mark~markRegExpNoMatchCallback} [noMatch] * @property {Mark~markRegExpFilterCallback} [filter] */ /** * Marks a custom regular expression * @param {RegExp} regexp - The regular expression * @param {Mark~markRegExpOptions} [opt] - Optional options object * @access public */ markRegExp(regexp, opt) { this.opt = opt; this.log(`Searching with expression "${regexp}"`); let totalMatches = 0; let fn = 'highlightMatches'; const eachCb = element => { totalMatches++; this.opt.each(element); }; if (this.opt.acrossElements) { fn = 'highlightMatchesAcrossElements'; } this[fn]( regexp, this.opt.ignoreGroups, (match, node) => { return this.opt.filter(node, match, totalMatches); }, eachCb, () => { if (totalMatches === 0) { this.opt.noMatch(regexp); } this.opt.done(totalMatches); } ); } /** * Callback to filter matches * @callback Mark~markFilterCallback * @param {HTMLElement} textNode - The text node which includes the match * @param {string} match - The matching term * @param {number} totalCounter - A counter indicating the number of all * marks * @param {number} termCounter - A counter indicating the number of marks * for the specific match */ /** * These options also include the common options from * {@link Mark~commonOptions} and the options from * {@link RegExpCreator~options} * @typedef Mark~markOptions * @type {object.<string>} * @property {boolean} [separateWordSearch=true] - Whether to search for * each word separated by a blank instead of the complete term * @property {Mark~markFilterCallback} [filter] */ /** * Marks the specified search terms * @param {string|string[]} [sv] - Search value, either a search string or an * array containing multiple search strings * @param {Mark~markOptions} [opt] - Optional options object * @access public */ mark(sv, opt) { this.opt = opt; let totalMatches = 0; let fn = 'highlightMatches'; const { keywords: kwArr, length: kwArrLen } = this.getSeparatedKeywords( typeof sv === 'string' ? [sv] : sv ); const handler = kw => { // async function calls as iframes are async too const regex = new RegExpCreator(this.opt).create(kw); let matches = 0; this.log(`Searching with expression "${regex}"`); this[fn]( regex, 1, (term, node) => { return this.opt.filter(node, kw, totalMatches, matches); }, element => { matches++; totalMatches++; this.opt.each(element); }, () => { if (matches === 0) { this.opt.noMatch(kw); } if (kwArr[kwArrLen - 1] === kw) { this.opt.done(totalMatches); } else { handler(kwArr[kwArr.indexOf(kw) + 1]); } } ); }; if (this.opt.acrossElements) { fn = 'highlightMatchesAcrossElements'; } if (kwArrLen === 0) { this.opt.done(totalMatches); } else { handler(kwArr[0]); } } /** * Callback for each marked element * @callback Mark~markRangesEachCallback * @param {HTMLElement} element - The marked DOM element * @param {array} range - array of range start and end points */ /** * Callback if a processed range is invalid, out-of-bounds, overlaps another * range, or only matches whitespace * @callback Mark~markRangesNoMatchCallback * @param {Mark~rangeObject} range - a range object */ /** * Callback to filter matches * @callback Mark~markRangesFilterCallback * @param {HTMLElement} node - The text node which includes the range * @param {array} range - array of range start and end points * @param {string} match - string extracted from the matching range * @param {number} counter - A counter indicating the number of all marks */ /** * These options also include the common options from * {@link Mark~commonOptions} without the each and noMatch callback * @typedef Mark~markRangesOptions * @type {object.<string>} * @property {Mark~markRangesEachCallback} [each] * @property {Mark~markRangesNoMatchCallback} [noMatch] * @property {Mark~markRangesFilterCallback} [filter] */ /** * Marks an array of objects containing a start with an end or length of the * string to mark * @param {Mark~setOfRanges} rawRanges - The original (preprocessed) * array of objects * @param {Mark~markRangesOptions} [opt] - Optional options object * @access public */ markRanges(rawRanges, opt) { this.opt = opt; let totalMatches = 0; let ranges = this.checkRanges(rawRanges); if (ranges && ranges.length) { this.log( 'Starting to mark with the following ranges: ' + JSON.stringify(ranges) ); this.highlightRangeFromIndex( ranges, (node, range, match, counter) => { return this.opt.filter(node, range, match, counter); }, (element, range) => { totalMatches++; this.opt.each(element, range); }, () => { this.opt.done(totalMatches); } ); } else { this.opt.done(totalMatches); } } /** * Removes all marked elements inside the context with their HTML and * normalizes the parent at the end * @param {Mark~commonOptions} [opt] - Optional options object without each, * noMatch and acrossElements properties * @access public */ unmark(opt) { this.opt = opt; let sel = this.opt.element ? this.opt.element : '*'; sel += '[data-markjs]'; if (this.opt.className) { sel += `.${this.opt.className}`; } this.log(`Removal selector "${sel}"`); this.iterator.forEachNode( NodeFilter.SHOW_ELEMENT, node => { this.unhighlightMatches(node); }, node => { const matchesSel = DOMIterator.matches(node, sel); const matchesExclude = this.matchesExclude(node); if (!matchesSel || matchesExclude) { return NodeFilter.FILTER_REJECT; } else { return NodeFilter.FILTER_ACCEPT; } }, this.opt.done ); } } export default Mark;