Mark class
import DOMIterator from './domiterator';
import RegExpCreator from './regexpcreator';
/**
* 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;
/**
* The array of node names which must be excluded from search
* @type {array}
* @access protected
*/
this.nodeNames = ['script', 'style', 'title', 'head', 'html'];
}
/**
* @typedef Mark~commonOptions
* @type {object.<string>}
* @property {object} [window] - A window object
* @property {Highlight} [highlight] - A Highlight object
* @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)
*/
/**
* 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) {
if ( !(val && val.window && val.window.document) && typeof window === 'undefined') {
throw new Error('Mark.js: please provide a window object as an option.');
}
const win = val && val.window || window,
// eslint-disable-next-line
highlight = val && val.highlight && val.highlight instanceof Highlight;
this._opt = Object.assign({}, {
'window': win,
'element': '',
'className': '',
'exclude': [],
'iframes': false,
'iframesTimeout': 5000,
'separateWordSearch': true,
'staticRanges': true,
'rangeAcrossElements': true,
'acrossElements': false,
'ignoreGroups': 0,
'each': () => {},
'noMatch': () => {},
'filter': () => true,
'done': () => {},
'debug': false,
'log': win.console
}, val);
if ( !this._opt.element) {
this._opt.element = 'mark';
}
// shortens a lengthy name
this.filter = win.NodeFilter;
// this empty text node used to simplify code
this.empty = win.document.createTextNode('');
if ( !this._opt.highlightName) {
this._opt.highlightName = 'advanced-markjs';
}
if (highlight) {
this.rangeArray = [];
} else {
this._opt.highlight = null;
}
}
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);
}
/**
* 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') {
if (this.opt.debug) {
const log = this.opt.log;
if (typeof log === 'object' && typeof log[level] === 'function') {
log[level](`mark.js: ${msg}`);
}
}
}
/**
* @typedef Mark~logObject
* @type {object}
* @property {string} message - The message
* @property {object} obj - The object
*/
/**
* Logs errors and info
* @param {array} array - The array of objects
*/
report(array) {
array.forEach(item => {
this.log(`${item.text} ${JSON.stringify(item.obj)}`, item.level || 'debug');
if ( !item.skip) {
this.opt.noMatch(item.obj);
}
});
}
/**
* Splits string into separate words if 'separateWordSearch' option has value 'true' but,
* if it has string value 'preserveTerms', prevents splitting terms surrounding by double quotes.
* Removes duplicate or empty entries and sort by the length in descending order.
* It also initializes termStats object.
* @param {string|string[]} sv - Search value, either a string or an array of strings
* @return {object}
* @access protected
*/
getSeachTerms(sv) {
const search = typeof sv === 'string' ? [sv] : sv,
separate = this.opt.separateWordSearch,
array = [],
termStats = {},
split = str => {
str.split(/ +/).forEach(word => add(word));
},
add = str => {
if (str.trim() && !array.includes(str)) {
array.push(str);
// initializes term property
termStats[str] = 0;
}
};
search.forEach(str => {
if (separate) {
if (separate === 'preserveTerms') {
// allows highlight quoted terms no matter how many quotes it contains on each side,
// e.g. ' ""term"" ' or ' """"term" '
str.split(/"("*[^"]+"*)"/).forEach((term, i) => {
if (i % 2 > 0) add(term);
else split(term);
});
} else {
split(str);
}
} else {
add(str);
}
});
array.sort((a, b) => b.length - a.length);
return { terms: array, termStats };
}
/**
* 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;
}
/**
* Filters valid ranges, sorts and, if wrapAllRanges option is false, filters out nesting/overlapping ranges
* @param {Mark~setOfRanges} array - unprocessed raw array
* @param {Mark~logObject} logs - The array of logs objects
* @return {Mark~setOfRanges} - processed array with any invalid entries removed
* @access protected
*/
checkRanges(array, logs, min, max) {
// a range object must have the start and length properties with numeric values
// [{start: 0, length: 5}, ..]
const level = 'error';
// filters and sorts valid ranges
const ranges = array.filter(range => {
if (this.isNumeric(range.start) && this.isNumeric(range.length)) {
range.start = parseInt(range.start);
range.length = parseInt(range.length);
if (range.start >= min && range.start < max && range.length > 0) {
return true;
}
}
logs.push({ text: 'Invalid range: ', obj: range, level });
return false;
}).sort((a, b) => a.start - b.start);
if (this.opt.wrapAllRanges) {
return ranges;
}
let lastIndex = 0, index;
// filters out nesting/overlapping ranges
return ranges.filter(range => {
index = range.start + range.length;
if (range.start >= lastIndex) {
lastIndex = index;
return true;
}
logs.push({ text: (index < lastIndex ? 'Nest' : 'Overlapp') + 'ing range: ', obj: range, level });
return false;
});
}
/**
* @typedef Mark~blockElementsBoundaryObject
* @type {object}
* @property {array} [tagNames] - The array of custom tag names
* @property {boolean} [extend] - Whether to extend the default boundary elements with custom elements
* or set only custom elements to boundary type
* @property {string} [char] - The custom separating char
*/
/**
* Sets type: 1 - separate by space, 2 - separate by boundary char with space(s)
* @param {object} tags - The object containing HTML element tag names
*/
setType(tags, boundary) {
const custom = Array.isArray(boundary.tagNames) && boundary.tagNames.length;
if (custom) {
// normalizes custom elements names and adds to the tags object with boundary type value
boundary.tagNames.forEach(name => tags[name.toLowerCase()] = 2);
}
// if not extend, the only custom tag names are set to a boundary type
if ( !custom || boundary.extend) {
// sets all tags value to the boundary
for (const key in tags) {
tags[key] = 2;
}
}
tags['br'] = 3;
}
/**
* @typedef Mark~nodeInfoAcross
* @property {Text} node - The DOM text node
* @property {number} start - The start index within the composite string
* @property {number} end - The end index within the composite string
* @property {number} offset - The offset is used to correct position if a space or string
* was added to end of composite string after this node textContent
*/
/**
* @typedef Mark~getTextNodesAcrossDict
* @type {object.<string>}
* @property {string} text - The composite string of all text nodes
* @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
* @property {number} lastIndex - The property used to store the nodes last index
*/
/**
* Callback
* @callback Mark~getTextNodesAcrossCallback
* @param {Mark~getTextNodesAcrossDict}
*/
/**
* 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~getTextNodesAcrossCallback} cb - Callback
* @access protected
*/
getTextNodesAcross(cb) {
// a space or string can be safely added to the end of a text node when two text nodes
// are 'separated' by element with one of these names
const tags = { div: 1, p: 1, li: 1, td: 1, tr: 1, th: 1, ul: 1,
ol: 1, dd: 1, dl: 1, dt: 1, h1: 1, h2: 1, h3: 1, h4: 1,
h5: 1, h6: 1, hr: 1, blockquote: 1, figcaption: 1, figure: 1,
pre: 1, table: 1, thead: 1, tbody: 1, tfoot: 1, input: 1,
img: 1, nav: 1, details: 1, label: 1, form: 1, select: 1, menu: 1,
br: 3, menuitem: 1,
main: 1, section: 1, article: 1, aside: 1, picture: 1, output: 1,
button: 1, header: 1, footer: 1, address: 1, area: 1, canvas: 1,
map: 1, fieldset: 1, textarea: 1, track: 1, video: 1, audio: 1,
body: 1, iframe: 1, meter: 1, object: 1, svg: 1 };
const nodes = [],
boundary = this.opt.blockElementsBoundary,
priorityType = boundary ? 2 : 1;
let ch = '\x01', tempType, type, prevNode;
if (boundary) {
this.setType(tags, boundary);
if (boundary.char) {
ch = boundary.char.charAt(0);
}
}
const obj = {
text: '', regex: /\s/, tags: tags,
boundary: boundary, str: '', ch: ch
};
this.iterator.forEachNode(this.filter.SHOW_ELEMENT | this.filter.SHOW_TEXT, node => { // each
if (prevNode) {
nodes.push(this.getNodeInfo(prevNode, node, type, obj));
}
type = null;
prevNode = node;
}, node => { // filter
if (node.nodeType === 1) { // element
tempType = tags[node.nodeName.toLowerCase()];
if (tempType === 3) { // br element
obj.str += '\n';
}
if ( !type || tempType === priorityType) {
type = tempType;
}
return false;
}
return !this.excluded(node.parentNode);
}, () => { // done
// processes the last node
if (prevNode) {
nodes.push(this.getNodeInfo(prevNode, null, type, obj));
}
cb({
text: obj.text,
nodes: nodes,
lastIndex: 0
});
});
}
/**
* Creates object
* @param {Text} prevNode - The previous DOM text node
* @param {Text} node - The current DOM text node
* @param {number|null} type - define how to separate the previous and current text nodes textContent;
* type is null when nodes doesn't separated by block elements
* @param {object} obj - The auxiliary object to pass multiple parameters to the method
*/
getNodeInfo(prevNode, node, type, obj) {
const start = obj.text.length,
ch = obj.ch;
let offset = 0,
str = obj.str,
text = prevNode.textContent;
if (node) {
const startBySpace = obj.regex.test(node.textContent[0]),
both = startBySpace && obj.regex.test(text[text.length - 1]);
if (obj.boundary || !both) {
let separate = type;
// searches for the first parent of the previous text node that met condition
// and checks does they have the same parent or the parent contains the current text node
if (!type) {
let parent = prevNode.parentNode;
while (parent) {
type = obj.tags[parent.nodeName.toLowerCase()];
if (type) {
separate = !(parent === node.parentNode || parent.contains(node));
break;
}
parent = parent.parentNode;
}
}
if (separate) {
if ( !both) {
str += type === 1 ? ' ' : type === 2 ? ' ' + ch + ' ' : '';
} else if (type === 2) {
str += both ? ch : startBySpace ? ' ' + ch : ch + ' ';
}
}
}
}
if (str) {
text += str;
offset = str.length;
obj.str = '';
}
obj.text += text;
return this.createInfo(prevNode, start, obj.text.length - offset, offset);
}
/**
* @typedef Mark~nodeInfo
* @property {Text} node - The DOM text node
* @property {number} start - The start index within the composite string
* @property {number} end - The end index within the composite string
* @property {number} offset - This property is required for compatibility with [Mark~nodeInfoAcross]
* for {@link Mark#markRanges}
*/
/**
* @typedef Mark~getTextNodesDict
* @type {object.<string>}
* @property {string} text - The composite value of all text nodes
* @property {Mark~nodeInfo[]} nodes - The array of objects
* @property {number} lastIndex - The property used to store the nodes last index
*/
/**
* 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
*/
getRangesTextNodes(cb, lines) {
const nodes = [],
regex = /\n/g,
newLines = [0],
show = this.filter.SHOW_TEXT | (lines ? this.filter.SHOW_ELEMENT : 0);
let text = '',
len = 0,
rm;
this.iterator.forEachNode(show, node => { // each
if (lines) {
while ((rm = regex.exec(node.textContent)) !== null) {
newLines.push(len + rm.index);
}
}
text += node.textContent;
nodes.push({
start: len,
end: (len = text.length),
offset: 0,
node: node
});
}, node => { // filter
if (lines && node.nodeType === 1) {
if (node.tagName.toLowerCase() === 'br') {
newLines.push(len);
}
return false;
}
return !this.excluded(node.parentNode);
}, () => { // done
const dict = { text, nodes, lastIndex: 0 };
if (lines) {
newLines.push(len);
dict.newLines = newLines;
}
cb(dict);
});
}
/**
* @typedef Mark~nodeInfo
* @property {Text} node - The DOM text node
* @property {number} start - The start index within the composite string
*/
/**
* @typedef Mark~getTextNodesDict
* @type {object.<string>}
* @property {Mark~nodeInfo[]} nodes - The array of objects
* @property {number} lastIndex - The property used to store the nodes last index
*/
/**
* Callback
* @callback Mark~getTextNodesCallback
* @param {Mark~getTextNodesDict}
*/
/**
* Calls the callback with an object containing all text nodes (including iframe text nodes)
* @param {Mark~getTextNodesCallback} cb - Callback
* @access protected
*/
getTextNodes(cb) {
const nodes = [];
let start = 0;
this.iterator.forEachNode(this.filter.SHOW_TEXT, node => { // each
nodes.push({
node,
start
});
start += node.textContent.length;
}, node => { // filter
return !this.excluded(node.parentNode);
}, () => { // done
cb({ nodes, lastIndex: 0 });
});
}
/**
* Checks if an element matches any of the specified exclude selectors.
* @param {HTMLElement} elem - The element to check
* @return {boolean}
* @access protected
*/
excluded(elem) {
// it's faster to check if an array contains the node name than a selector in 'DOMIterator.matches()'
// also it allows using a string of selectors instead of an array with the 'exclude' option
return this.nodeNames.includes(elem.nodeName.toLowerCase()) || DOMIterator.matches(elem, this.opt.exclude);
}
/**
* Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
* Creates info object(s) related to the newly created node(s) and inserts into dict.nodes or replace an existing one
* It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
* @param {Mark~wrapRangeInsertDict} dict - The dictionary
* @param {object} n - The currently processed info object
* @param {number} s - The position where to start wrapping
* @param {number} e - The position where to end wrapping
* @param {number} start - The start position of the match
* @param {number} index - The current index of the processed object
* @return {object} Returns object containing the mark element, the splitted text node
* that will appear after the wrapped text node, and increment number
*/
wrapRangeInsert(dict, n, s, e, start, index) {
const ended = e === n.node.textContent.length,
end = n.end;
// type: 0 - whole text node, 1 - from the start, 2 - to the end, 3 - between
let type = 1,
splitIndex = e,
node = n.node;
// prevents creating empty sibling text nodes at the start/end of a text node
if (s !== 0) {
node = node.splitText(s);
splitIndex = e - s;
type = ended ? 2 : 3;
} else if (ended) { // whole
type = 0;
}
const retNode = ended ? this.empty : node.splitText(splitIndex),
mark = this.createElement(node),
markChild = mark.childNodes[0],
nodeInfo = this.createInfo(retNode, type === 0 || type === 2 ? end : n.start + e, end, n.offset);
if (type === 0) {
n.node = markChild;
return { mark, nodeInfo, increment: 0 };
}
const info = this.createInfo(markChild, type === 1 ? n.start : start, n.start + e, 0);
// inserts new node(s) info in dict.nodes depending where a range is located in a text node
if (type === 1) {
dict.nodes.splice(index, 1, info, nodeInfo);
} else {
if (type === 2) {
dict.nodes.splice(index + 1, 0, info);
} else {
dict.nodes.splice(index + 1, 0, info, nodeInfo);
}
n.end = start;
n.offset = 0;
}
return { mark, nodeInfo, increment: type < 3 ? 1 : 2 };
}
/**
* Creates object
* @param {Text} node - The DOM text node
* @param {number} start - The position where to start wrapping
* @param {number} end - The position where to end wrapping
* @param {number} offset - The length of space/string that is added to end of composite string
* after this node textContent
*/
createInfo(node, start, end, offset) {
return { node, start, end, offset };
}
/**
* Each callback
* @callback Mark~wrapRangeEachCallback
* @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
*/
/**
* Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
* It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
* @param {Text} node - The DOM text node
* @param {number} start - The position where to start wrapping
* @param {number} end - The position where to end wrapping
* @param {Mark~wrapRangeEachCallback} eachCb - Each callback
* @return {Text}
* @access protected
*/
wrapRange(n, start, end, eachCb) {
let node = n.node,
retNode;
if (this.rangeArray) {
this.createRange(node, start, node, end, n.start + start, eachCb);
retNode = node;
} else {
let ended = end === node.textContent.length,
index = end;
if (start !== 0) {
node = node.splitText(start);
index = end - start;
}
retNode = ended ? this.empty : node.splitText(index);
eachCb(this.createElement(node));
}
return retNode;
}
/**
* Each callback
* @callback Mark~createRangeEachCallback
* @param {StaticRange|Range} range - The created range
* @param {boolean} true - Required only for across elements code with the rangeAcrossElements option
*/
/**
* Creates the new StaticRange/Range object with the specified parameters
* @param {Text} startNode - The text node where a match is started
* @param {number} startOffset - The start index of the match in startNode
* @param {number} endNode - The text node where a match is ended
* @param {number} endOffset - The end index of the match in endNode
* @param {number} absoluteOffset - The absolute start index from the beginning of first context.
* Uses to sort ranges by ascending order.
* @param {Mark~createRangeEachCallback} eachCb - Each callback
*/
createRange(startNode, startOffset, endNode, endOffset, absoluteOffset, eachCb) {
let range;
if (this.opt.staticRanges) {
range = new StaticRange({ startContainer: startNode, startOffset, endContainer: endNode, endOffset });
} else {
range = new Range();
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
}
range.absoluteOffset = absoluteOffset;
eachCb(range, true);
// a range can be destroyed on the 'each' callback
if (range) this.rangeArray.push(range);
}
/**
* Wraps the new element with the necessary attributes around text node
* @param {Text} node - The DOM text node
* @return {HTMLElement} Returns the created DOM node
*/
createElement(node) {
let markNode = this.opt.window.document.createElement(this.opt.element);
markNode.setAttribute('data-markjs', 'true');
if (this.opt.className) {
markNode.setAttribute('class', this.opt.className);
}
markNode.textContent = node.textContent;
node.parentNode.replaceChild(markNode, node);
return markNode;
}
/**
* @typedef Mark~wrapRangeAcrossDict
* @type {object.<string>}
* @property {string} text - The composite string of all text nodes
* @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
* @property {number} lastIndex - The property used to store the nodes last index
*/
/**
* Each callback
* @callback Mark~wrapRangeAcrossEachCallback
* @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
* @param {boolean} rangeStart - Indicate the start of the current range or always true (Highlight API)
*/
/**
* Filter callback
* @callback Mark~wrapRangeAcrossFilterCallback
* @param {Text|Text[]} node - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
*/
/**
* Determines matches by start and end positions using the text node dictionary
* and calls {@link Mark#wrapRange} or {@link Mark#wrapRangeInsert} to wrap them
* @param {Mark~wrapRangeAcrossDict} dict - The dictionary
* @param {number} start - The start index of the match
* @param {number} end - The end index of the match
* @param {Mark~wrapRangeAcrossFilterCallback} filterCb - Filter callback
* @param {Mark~wrapRangeAcrossEachCallback} eachCb - Each callback
* @access protected
*/
wrapRangeAcross(dict, start, end, filterCb, eachCb) {
// dict.lastIndex stores the last node index to avoid iteration from the beginning
let i = dict.lastIndex,
rangeStart = true,
startInfo,
filterNodes = [],
e;
const wrapAllRanges = this.opt.wrapAllRanges,
highlightAPI = !!this.opt.highlight, // when using the Highlight API, no text nodes are split
singleRange = highlightAPI && this.opt.rangeAcrossElements;
if (wrapAllRanges) {
// finds the start index in case of nesting/overlapping
while (i > 0 && dict.nodes[i].start > start) {
i--;
}
}
for (i; i < dict.nodes.length; i++) {
if (i + 1 === dict.nodes.length || dict.nodes[i+1].start > start) {
let n = dict.nodes[i];
if (singleRange) {
filterNodes.push(n.node);
} else if ( !filterCb(n.node)) {
break;
}
// map range from dict.text to text node
const s = start - n.start;
e = (end > n.end ? n.end : end) - n.start;
// prevents creating an empty mark node, prevents exception if something went wrong, useful for debug
if (s >= 0 && e > s) {
if (singleRange) {
if (rangeStart) {
startInfo = [n.node, s, n.start + s];
}
} else if ( !highlightAPI && wrapAllRanges) {
const obj = this.wrapRangeInsert(dict, n, s, e, start, i);
n = obj.nodeInfo;
eachCb(obj.mark, rangeStart);
} else {
// when using the Highlight API and the 'rangeAcrossElements: false',
// it creates multiple ranges for matches that are located across elements
n.node = this.wrapRange(n, s, e, elemOrRange => {
eachCb(elemOrRange, rangeStart);
});
// sets the new text node start index in the case of subsequent matches in the same text node
if ( !highlightAPI) n.start += e;
}
rangeStart = false;
}
if (end > n.end) {
// the range extends to the next text node
start = n.end + n.offset;
} else {
// creates a single StaticRange/Range for matches that are located across elements
if (startInfo && filterCb(filterNodes)) {
this.createRange(startInfo[0], startInfo[1], n.node, e, startInfo[2], eachCb);
}
break;
}
}
}
// sets the last index
dict.lastIndex = i;
}
/**
* Filter callback before each wrapping
* @callback Mark~wrapGroupsFilterCallback
* @param {Text} node - The text node where the match occurs
* @param {string} group - The current group matching string
* @param {number} i - The current group index
*/
/**
* Callback for each wrapped element
* @callback Mark~wrapGroupsEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {number} i - The current group index
*/
/**
* Wraps match groups with RegExp.hasIndices
* @param {Text} node - The text node where the match occurs
* @param {array} match - The result of RegExp exec() method
* @param {RegExp} regex - The regular expression
* @param {Mark~wrapGroupsFilterCallback} filterCb - Filter callback
* @param {Mark~wrapGroupsEachCallback} eachCb - Each callback
*/
wrapGroups(n, match, regex, filterCb, eachCb) {
let lastIndex = 0,
offset = 0,
i = 0,
highlightAPI = this.opt.highlight,
isWrapped = false,
group, start, end = 0;
while (++i < match.length) {
group = match[i];
if (group) {
start = match.indices[i][0];
//it prevents marking nested group - parent group is already marked
if (start >= lastIndex) {
end = match.indices[i][1];
if (filterCb(n.node, group, i)) {
// when a group is wrapping, a text node is split at the end index,
// so to correct the start & end indexes of a new text node, subtract
// the end index of the last wrapped group (offset)
n.node = this.wrapRange(n, start - offset, end - offset, elemOrRange => { // each
eachCb(elemOrRange);
});
if (end > lastIndex) {
lastIndex = end;
}
// when using the Highlight API, no text nodes are split
if ( !highlightAPI) offset = end;
isWrapped = true;
}
}
}
}
// resets the lastIndex when at least one group is wrapped (prevents infinite loop)
if (isWrapped) {
if ( !highlightAPI) regex.lastIndex = 0;
} else if (match[0].length === 0) {
this.setLastIndex(regex, end);
}
}
/**
* Filter callback before each wrapping
* @callback Mark~wrapGroupsAcrossFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {string} group - The current group matching string
* @param {number} i - The current group index
*/
/**
* Callback for each wrapped element
* @callback Mark~wrapGroupsAcrossEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {number} i - The current group index
* @param {boolean} groupStart - Indicate the start of a group
*/
/**
* Wraps match groups with RegExp.hasIndices across elements
* @param {Mark~wrapGroupsAcrossDict} dict - The dictionary
* @param {array} match - The result of RegExp exec() method
* @param {RegExp} regex - The regular expression
* @param {Mark~wrapGroupsAcrossFilterCallback} filterCb - Filter callback
* @param {Mark~wrapGroupsAcrossEachCallback} eachCb - Each callback
*/
wrapGroupsAcross(dict, match, regex, filterCb, eachCb) {
let lastIndex = 0,
i = 0,
end = 0,
start,
group,
isWrapped;
while (++i < match.length) {
group = match[i];
if (group) {
start = match.indices[i][0];
// the wrapAllRanges option allows wrapping nested group(s),
// the 'start >= lastIndex' prevents wrapping nested group(s) - the parent group is already wrapped
if (this.opt.wrapAllRanges || start >= lastIndex) {
end = match.indices[i][1];
isWrapped = false;
this.wrapRangeAcross(dict, start, end, nodeOrArray => { // filter
return filterCb(nodeOrArray, group, i);
}, (elemOrRange, groupStart) => { // each
isWrapped = true;
eachCb(elemOrRange, groupStart);
});
// group may be filtered out
if (isWrapped && end > lastIndex) {
lastIndex = end;
}
}
}
}
if (match[0].length === 0) {
this.setLastIndex(regex, end);
}
}
/**
* When the length of a match is zero, there is a need to set the RegExp lastIndex depending on conditions.
* It's necessary to avoid infinite loop and set position from which to start the next match
* @param {RegExp} regex - The regular expression
* @param {number} end - The end index of the last processed group
*/
setLastIndex(regex, end) {
const index = regex.lastIndex;
// end > index - case when a capturing group is inside positive lookahead assertion
// end > 0 - case when a match is filtered out or a capturing group is inside positive lookbehind assertion
regex.lastIndex = end > index ? end : end > 0 ? index + 1 : Infinity;
}
/**
* @typedef Mark~filterInfoObject
* @type {object}
* @property {array} match - The result of RegExp exec() method
* @property {boolean} matchStart - Indicate the start of match. It's only available
* with the 'acrossElements' option
* @property {number} groupIndex - The group index. It's only available
* with 'separateGroups' option
* @property {object} execution - The helper object for early abort. Contains
* boolean 'abort' property.
*/
/**
* @typedef Mark~eachInfoObject
* @type {object}
* @property {array} match - The result of RegExp exec() method
* @property {boolean} matchStart - Indicate the start of match. It's only available
* with the 'acrossElements' option
* @property {number} count - The current number of matches
* @property {number} groupIndex - The index of current match group. It's only
* available with 'separateGroups' option
* @property {boolean} groupStart - Indicate the start of group. It's only
* available with both 'acrossElements' and 'separateGroups' options
*/
/**
* @typedef Mark~infoObject
* @type {object}
* @property {number} count - The number of matches so far
* @property {object} execution - The helper object for early abort. Contains boolean 'abort' property.
*/
/**
* Group filter callback before each wrapping
* @callback Mark~processGroupsFilterCallback
* @param {Text} node - The text node where the match occurs
* @param {string} group - The matching string of the current group
* @param {Mark~filterInfoObject} info - The object containing the match information
*/
/**
* Callback for each wrapped element
* @callback Mark~processGroupsEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} - The object containing the match information
*/
/**
* Callback on end
* @callback Mark~processGroupsEndCallback
* @param {number} count - The number of matches
*/
/**
* Wraps match capturing groups
* @param {RegExp} regex - The regular expression to be searched for
* @param {number} unused
* @param {Mark~infoObject} info - The object used on filter and each callbacks
* @param {Mark~processGroupsFilterCallback} filterCb - Filter callback
* @param {Mark~processGroupsEachCallback} eachCb - Each callback
* @param {Mark~processGroupsEndCallback} endCb
* @access protected
*/
processGroups(regex, unused, info, filterCb, eachCb, endCb) {
let count = info.count, match, filterStart, eachStart;
this.getTextNodes(dict => {
dict.nodes.every(n => {
while ((match = regex.exec(n.node.textContent)) !== null) {
info.match = match;
filterStart = eachStart = true;
this.wrapGroups(n, match, regex, (node, group, grIndex) => { // filter
info.matchStart = filterStart;
info.groupIndex = grIndex;
filterStart = false;
return filterCb(node, group, info);
}, (elemOrRange) => { // each
if (eachStart) count++;
info.count = count;
info.matchStart = eachStart;
eachStart = false;
eachCb(elemOrRange, info);
});
if (info.abort) break;
}
// breaks loop on custom abort
return !info.abort;
});
endCb(count);
});
}
/**
* Filter callback before each wrapping
* @callback Mark~processGroupsAcrossFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {string} group - The matching string of the current group
* @param {Mark~filterInfoObject} info - The object containing the match information
*/
/**
* Callback for each wrapped element
* @callback Mark~processGroupsAcrossEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} - The object containing the match information
*/
/**
* Callback on end
* @callback Mark~processGroupsAcrossEndCallback
* @param {number} count - The number of all matches
*/
/**
* Wraps match capturing groups across elements
* @param {RegExp} regex - The regular expression to be searched for
* @param {number} unused
* @param {Mark~infoObject} info - The object used on filter and each callbacks
* @param {Mark~processGroupsAcrossFilterCallback} filterCb - Filter callback
* @param {Mark~processGroupsAcrossEachCallback} eachCb - Each callback
* @param {Mark~processGroupsAcrossEndCallback} endCb
* @access protected
*/
processGroupsAcross(regex, unused, info, filterCb, eachCb, endCb) {
let count = info.count, match, filterStart, eachStart;
this.getTextNodesAcross(dict => {
while ((match = regex.exec(dict.text)) !== null) {
info.match = match;
filterStart = eachStart = true;
this.wrapGroupsAcross(dict, match, regex, (nodeOrArray, group, grIndex) => { // filter
// eslint-disable-next-line
info.groupStart = undefined;
info.matchStart = filterStart;
info.groupIndex = grIndex;
filterStart = false;
return filterCb(nodeOrArray, group, info);
}, (elemOrRange, groupStart) => { // each
if (eachStart) count++;
info.count = count;
info.matchStart = eachStart;
info.groupStart = groupStart;
eachCb(elemOrRange, info);
eachStart = false;
});
// breaks loop on custom abort
if (info.abort) break;
}
endCb(count);
});
}
/**
* Filter callback before each wrapping
* @callback Mark~processMatchesFilterCallback
* @param {Text} node - The text node where the match occurs
* @param {string} str - The matching string
* @param {Mark~filterInfoObject} filterInfo - The object containing the match information
*/
/**
* Callback for each wrapped element
* @callback Mark~processMatchesEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} eachInfo - The object containing the match information
*/
/**
* Callback on end
* @callback Mark~processMatchesEndCallback
* @param {number} count - The number of all matches
*/
/**
* 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 of RegExp capturing groups to ignore from the beginning of a match
* @param {Mark~infoObject} info - The object used on filter and each callbacks
* @param {Mark~processMatchesFilterCallback} filterCb - Filter callback
* @param {Mark~processMatchesEachCallback} eachCb - Each callback
* @param {Mark~processMatchesEndCallback} endCb
* @access protected
*/
processMatches(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
let count = info.count, match, str;
this.getTextNodes(dict => {
dict.nodes.every(n => {
while ((match = regex.exec(n.node.textContent)) !== null) {
// prevents an infinite loop
if ((str = match[index]) === '') {
regex.lastIndex++;
continue;
}
info.match = match;
if ( !filterCb(n.node, str, info)) {
continue;
}
// calculates the start index inside node.textContent
let i = 0, start = match.index;
while (++i < index) {
if (match[i]) { // allows any ignore group to be undefined
start += match[i].length;
}
}
n.node = this.wrapRange(n, start, start + str.length, elemOrRange => {
info.count = ++count;
eachCb(elemOrRange, info);
});
// when using the Highlight API, no text nodes are split
if ( !this.opt.highlight) regex.lastIndex = 0;
if (info.abort) break;
}
// breaks loop on custom abort
return !info.abort;
});
endCb(count);
});
}
/**
* Filter callback before each wrapping
* @callback Mark~processMatchesAcrossFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {string} str - The matching string
* @param {Mark~filterInfoObject} filterInfo - The object containing the match information
*/
/**
* Callback for each wrapped element
* @callback Mark~processMatchesAcrossEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} - The object containing the match information
*/
/**
* Callback on end
* @callback Mark~processMatchesAcrossEndCallback
* @param {number} count - The number of all matches
*/
/**
* 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 of RegExp capturing groups to ignore from the beginning of a match
* @param {Mark~infoObject} info - The object used on filter and each callbacks
* @param {Mark~processMatchesAcrossFilterCallback} filterCb - Filter callback
* @param {Mark~processMatchesAcrossEachCallback} eachCb - Each callback
* @param {Mark~processMatchesAcrossEndCallback} endCb
* @access protected
*/
processMatchesAcross(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
let count = info.count, match, str, matchStart;
this.getTextNodesAcross(dict => {
while ((match = regex.exec(dict.text)) !== null) {
// prevents an infinite loop
if ((str = match[index]) === '') {
regex.lastIndex++;
continue;
}
info.match = match;
matchStart = true;
// calculates the start index inside dict.text
let i = 0, start = match.index;
while (++i < index) {
if (match[i]) { // allows any ignore group to be undefined
start += match[i].length;
}
}
this.wrapRangeAcross(dict, start, start + str.length, nodeOrArray => { // filter
info.matchStart = matchStart;
matchStart = false;
return filterCb(nodeOrArray, str, info);
}, (elemOrRange, mStart) => { // each
if (mStart) count++;
info.count = count;
info.matchStart = mStart;
eachCb(elemOrRange, info);
});
if (info.abort) break;
}
endCb(count);
});
}
/**
* Callback for each wrapped element
* @callback Mark~wrapRangesEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~rangeObject} range - the current range object; the start and length values can be
* modified if they are not numeric integers
* @param {Mark~rangeInfoObject} rangeInfo - The object containing the range information
*/
/**
* Filter callback before each wrapping
* @callback Mark~wrapRangesFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {Mark~rangeObject} range - the current range object
* @param {string} substr - string extracted from the matching range
* @param {number} index - The current range index ???
*/
/**
* Callback on end
* @callback Mark~wrapRangesEndCallback
* @param {number} count - The number of wrapped ranges
* @param {Mark~logObject[]} logs - The array of objects
*/
/**
* Wraps the indicated ranges across all HTML elements in all contexts
* @param {Mark~setOfRanges} ranges
* @param {Mark~wrapRangesFilterCallback} filterCb
* @param {Mark~wrapRangesEachCallback} eachCb
* @param {Mark~wrapRangesEndCallback} endCb
* @access protected
*/
processRanges(ranges, filterCb, eachCb, endCb) {
const lines = this.opt.markLines,
logs = [],
skipped = [],
level = 'warn';
let count = 0;
this.getRangesTextNodes(dict => {
const max = lines ? dict.newLines.length : dict.text.length,
array = this.checkRanges(ranges, logs, lines ? 1 : 0, max);
array.forEach((range, index) => {
let start = range.start,
end = start + range.length;
if (end > max) {
// with wrapAllRanges option, there can be several report of limited ranges
logs.push({ text: `Range was limited to: ${max}`, obj: range, skip: true, level });
end = max;
}
if (lines) {
start = dict.newLines[start-1];
if (dict.text[start] === '\n') {
start++;
}
end = dict.newLines[end-1];
}
const substr = dict.text.slice(start, end);
if (substr.trim()) {
this.wrapRangeAcross(dict, start, end, nodeOrArray => { // filter
return filterCb(nodeOrArray, range, substr, index);
}, (elemOrRange, rangeStart) => { // each
if (rangeStart) {
count++;
}
eachCb(elemOrRange, range, {
matchStart: rangeStart,
count: count
});
});
} else {
// whitespace only; even if wrapped it is not visible
logs.push({ text: 'Skipping whitespace only range: ', obj: range, level });
skipped.push(range);
}
});
this.log(`Valid ranges: ${JSON.stringify(array.filter(range => !skipped.includes(range)))}`);
endCb(count, logs);
}, lines);
}
/**
* Unwraps the specified DOM node with its content (text nodes or HTML)
* without destroying possibly present events (using innerHTML) and normalizes text nodes
* @param {HTMLElement} node - The DOM node to unwrap
* @access protected
*/
unwrapMatches(node) {
const parent = node.parentNode,
first = node.firstChild;
if (node.childNodes.length === 1) {
// unwraps and normalizes text nodes
if (first.nodeType === 3) {
// the most common case - mark element with child text node
const previous = node.previousSibling,
next = node.nextSibling;
if (previous && previous.nodeType === 3) {
if (next && next.nodeType === 3) {
previous.nodeValue += first.nodeValue + next.nodeValue;
parent.removeChild(next);
} else {
previous.nodeValue += first.nodeValue;
}
} else if (next && next.nodeType === 3) {
next.nodeValue = first.nodeValue + next.nodeValue;
} else {
parent.replaceChild(node.firstChild, node);
return;
}
parent.removeChild(node);
} else {
// most likely is a nested mark element or modified by user element
parent.replaceChild(node.firstChild, node);
}
} else {
if ( !first) {
// an empty mark element
parent.removeChild(node);
} else {
// most likely is a nested mark element(s) with sibling text node(s) or modified by user element(s)
let docFrag = this.opt.window.document.createDocumentFragment();
while (node.firstChild) {
docFrag.appendChild(node.removeChild(node.firstChild));
}
parent.replaceChild(docFrag, node);
}
parent.normalize();
}
}
/**
* Callback to filter matches
* @callback Mark~markRegExpFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {string} match - The matching string:
* 1) without 'ignoreGroups' and 'separateGroups' options - the whole match.
* 2) with 'ignoreGroups' option - the match[ignoreGroups+1] group matching string.
* 3) with 'separateGroups' option - the current group matching string
* @param {number} matchesSoFar - The number of wrapped matches so far
* @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
*/
/**
* Callback for each marked element
* @callback Mark~markRegExpEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
*/
/**
* Callback if there were no matches
* @callback Mark~markRegExpNoMatchCallback
* @param {RegExp} regexp - The regular expression
*/
/**
* These options also include the common options from {@link Mark~commonOptions}
* @typedef Mark~markRegExpOptions
* @type {object.<string>}
* @property {number} [ignoreGroups=0] - A number of RegExp capturing groups to ignore from the beginning of a match
* @property {boolean} [separateGroups] - Whether to mark RegExp capturing groups instead of whole match
* @property {Mark~markRegExpNoMatchCallback} [noMatch]
* @property {Mark~markRegExpFilterCallback} [filter]
* @property {Mark~markRegExpEachCallback} [each]
*/
/**
* 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;
let totalMarks = 0,
matchesSoFar = 0,
across = this.opt.acrossElements,
fn = 'processMatches';
if (this.opt.separateGroups) {
if ( !regexp.hasIndices) {
throw new Error('Mark.js: RegExp must have a `d` flag');
}
fn = across ? 'processGroupsAcross' : 'processGroups';
} else if (across) {
fn = 'processMatchesAcross';
}
const info = { count: 0, abort: false };
// solves backward-compatibility
if ( !regexp.global && !regexp.sticky) {
let splits = regexp.toString().split('/');
regexp = new RegExp(regexp.source, 'g' + splits[splits.length-1]);
this.log('RegExp is recompiled - it must have a `g` flag', 'warn');
}
this.log(`RegExp "${regexp}"`);
this[fn](regexp, this.opt.ignoreGroups, info, (nodeOrArray, match, filterInfo) => { // filter
return this.opt.filter(nodeOrArray, match, matchesSoFar, filterInfo);
}, (elemOrRange, eachInfo) => { // each
matchesSoFar = eachInfo.count;
totalMarks++;
this.opt.each(elemOrRange, eachInfo);
}, (totalMatches) => { // done
if (totalMatches === 0) {
this.opt.noMatch(regexp);
}
this.registerHighlight();
this.opt.done(totalMarks, totalMatches);
});
}
/**
* Callback to filter matches
* @callback Mark~markFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {string} term - The current term
* @param {number} matches - The number of all wrapped matches so far
* @param {number} termMatches - The number of wrapped matches for the current term so far
* @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
*/
/**
* Callback for each marked element
* @callback Mark~markEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
*/
/**
* Callback if there were no matches
* @callback Mark~markNoMatchCallback
* @param {string[]} array - Not found search terms
*/
/**
* Callback when finished
* @callback Mark~commonDoneCallback
* @param {number} totalMatches - The total number of matches
* @param {object} termStats - The object containing an individual term's matches counts for {@link Mark#mark} method
*/
/**
* These options also include the common options from {@link Mark~commonOptions}
* @typedef Mark~markOptions
* @type {object.<string>}
* @property {boolean} [separateWordSearch=true] - Whether to break term into words
* and search for individual word instead of the complete term
* @property {Mark~markFilterCallback} [filter]
*/
/**
* Marks the specified search terms
* @param {string|string[]} [sv] - A search string or an array of search strings
* @param {Mark~markOptions} [opt] - Optional options object
* @access public
*/
mark(sv, opt) {
this.opt = opt;
const { terms, termStats } = this.getSeachTerms(sv);
if ( !terms.length) {
this.opt.done(0, 0, termStats);
return;
}
let index = 0,
totalMarks = 0,
matchesSoFar = 0,
term;
const across = this.opt.acrossElements,
fn = across ? 'processMatchesAcross' : 'processMatches',
array = this.getRegExps(terms),
info = { count: 0, abort: false };
const loop = ({ regex, regTerms }) => {
this.log(`RegExp ${regex}`);
this[fn](regex, 1, info, (nodeOrArray, _, filterInfo) => { // filter
if ( !across || filterInfo.matchStart) {
term = this.getCurrentTerm(filterInfo.match, regTerms);
}
// termStats[term] is the number of wrapped matches so far for the current term
return this.opt.filter(nodeOrArray, term, matchesSoFar, termStats[term], filterInfo);
}, (elemOrRange, eachInfo) => { // each
totalMarks++;
matchesSoFar = eachInfo.count;
if ( !across || eachInfo.matchStart) {
termStats[term] += 1;
}
this.opt.each(elemOrRange, eachInfo);
}, (totalMatches) => { // end
const noMatches = regTerms.filter(term => termStats[term] === 0);
if (noMatches.length) {
this.opt.noMatch(noMatches);
}
if ( !info.abort && ++index < array.length) {
loop(array[index]);
} else {
this.registerHighlight();
this.opt.done(totalMarks, totalMatches, termStats);
}
});
};
loop(array[0]);
}
/**
* @param {array} match - The result of RegExp exec() method
* @param {array} terms - The array of strings
* @return {string} - The matched term
*/
getCurrentTerm(match, terms) {
// it's better to search from the end of array because the terms are sorted by
// length in descending order - shorter term appears more frequently
let i = match.length;
while (--i > 2) {
// the current term index is the first non-undefined capturing group index minus 3
if (match[i]) {
// the first 3 groups are: match[0], lookbehind, and main group
return terms[i-3];
}
}
return ' ';
}
/**
* Splits an array of strings into chunks by the specified number and creates RegExp from each chunk
* @param {array} terms - The array of strings
* @return {array} - The array of arrays with RegExp and its term chunks
*/
getRegExps(terms) {
const creator = new RegExpCreator(this.opt),
option = this.opt.combineBy || this.opt.combinePatterns,
length = terms.length,
array = [];
let num = 100,
value;
if (option === Infinity) {
num = length;
} else if ( !isNaN(+option) && (value = parseInt(option)) > 0) {
num = value;
}
for (let i = 0; i < length; i += num) {
// get a chunk of terms to create combine pattern
const chunk = terms.slice(i, Math.min(i + num, length));
array.push({ regex: creator.create(chunk), regTerms: chunk });
}
return array;
}
/**
* @typedef Mark~rangeObject
* @type {object}
* @property {number} start - The start index within the composite string
* @property {number} length - The length of the string to mark within the composite string.
*/
/**
* @typedef Mark~setOfRanges
* @type {object[]}
* @property {Mark~rangeObject}
*/
/**
* @typedef Mark~rangeInfoObject
* @type {object}
* @property {boolean} matchStart - Indicate the start of range
* @property {number} count - The current number of wrapped ranges
*/
/**
* These options also include the common options from {@link Mark~commonOptions}
* @typedef Mark~markRangesOptions
* @type {object.<string>}
* @property {Mark~markRangesEachCallback} [each]
* @property {Mark~markRangesNoMatchCallback} [noMatch]
* @property {Mark~markRangesFilterCallback} [filter]
*/
/**
* Callback to filter matches
* @callback Mark~markRangesFilterCallback
* @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
* with the options 'acrossElements: true' and 'rangeAcrossElements: true'
* @param {Mark~rangeObject} range - The range object
* @param {string} match - The current range matching string
* @param {number} index - The current range index ???
*/
/**
* Callback for each marked element
* @callback Mark~markRangesEachCallback
* @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
* @param {Mark~rangeObject} range - The range object
* @param {Mark~rangeInfoObject} - The object containing the range information
*/
/**
* Callback if a processed range is invalid, out-of-bounds, overlaps another
* range, or only matches whitespace
* @callback Mark~markRangesNoMatchCallback
* @param {Mark~rangeObject} range - The range object
*/
/**
* Marks an array of objects containing start and length properties
* @param {Mark~setOfRanges} ranges - The original array of objects
* @param {Mark~markRangesOptions} [opt] - Optional options object
* @access public
*/
markRanges(ranges, opt) {
this.opt = opt;
if (Array.isArray(ranges)) {
let totalMarks = 0;
this.processRanges(ranges, (nodeOrArray, range, match, index) => { // filter
return this.opt.filter(nodeOrArray, range, match, index);
}, (elemOrRange, range, rangeInfo) => { // each
totalMarks++;
this.opt.each(elemOrRange, range, rangeInfo);
}, (totalRanges, logs) => { // end
this.report(logs);
this.registerHighlight();
this.opt.done(totalMarks, totalRanges);
});
} else {
this.report([{ text: 'markRanges() accept an array of objects: ', obj: ranges, level: 'error' }]);
this.opt.done(0, 0);
}
}
/**
* Iterates over specified names or the default name, if the HighlightRegistry contains name,
* it deletes all (if the 'exclude' option is specified according this option) ranges from the Highlight object;
* next, if allowed, unwraps all marked elements inside the context and normalizes text nodes
* @param {Mark~commonOptions} [opt] - Optional options object without each,
* noMatch and acrossElements properties
* @access public
*/
unmark(opt) {
this.opt = opt;
const registry = CSS.highlights,
exclude = this.opt.exclude && this.opt.exclude.length;
// if the browser supports the Highlight API
if (registry) {
let names = this.opt.highlightName,
highlight;
if (typeof names === 'string') names = [names];
names.forEach((name) => {
if ((highlight = registry.get(name)) && highlight.size) {
// unregister the Highlight object before deleting ranges
registry.delete(name);
// iterates over highlight when 'exclude' option is specified
if (exclude) {
highlight.forEach((range) => {
let node = range.startContainer;
if (node.nodeType === 3) node = node.parentNode;
if ( !this.excluded(node)) highlight.delete(range);
});
} else {
// much faster way to remove highlights
highlight.clear();
}
// register the Highlight object with excluded ranges
if (highlight.size) registry.set(name, highlight);
}
});
}
// removes only StaticRange/Range objects
if (this.opt.highlight) {
this.opt.done();
return;
}
let selector = this.opt.element + '[data-markjs]';
if (this.opt.className) {
selector += `.${this.opt.className}`;
}
this.log(`Removal selector "${selector}"`);
this.iterator.forEachNode(this.filter.SHOW_ELEMENT, node => { // each
this.unwrapMatches(node);
}, node => { // filter
return DOMIterator.matches(node, selector) && !(exclude && this.excluded(node));
}, this.opt.done);
}
/**
* Registers a Highlight object using the HighlightRegistry
*/
registerHighlight() {
const highlight = this.opt.highlight;
if (highlight) {
const name = this.opt.highlightName,
// eslint-disable-next-line
registry = CSS.highlights;
if (this.rangeArray.length) {
registry.delete(name);
if (highlight.size) {
highlight.forEach(range => {
this.rangeArray.push(range);
});
highlight.clear();
}
this.rangeArray.sort((a, b) => a.absoluteOffset - b.absoluteOffset);
this.rangeArray.forEach(range => {
highlight.add(range);
});
this.rangeArray = [];
}
if (highlight.size) registry.set(name, highlight);
}
}
}
export default Mark;