@ohos-ports/advanced-mark.js 3.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1817 @@
1
+ import DOMIterator from './domiterator';
2
+ import RegExpCreator from './regexpcreator';
3
+
4
+ /**
5
+ * Marks search terms in DOM elements
6
+ * @example
7
+ * new Mark(document.querySelector('.context')).mark('lorem ipsum');
8
+ * @example
9
+ * new Mark(document.querySelector('.context')).markRegExp(/lorem/gmi);
10
+ * @example
11
+ * new Mark('.context').markRanges([{start:10,length:0}]);
12
+ */
13
+ class Mark {
14
+
15
+ /**
16
+ * @param {HTMLElement|HTMLElement[]|NodeList|string} ctx - The context DOM
17
+ * element, an array of DOM elements, a NodeList or a selector
18
+ */
19
+ constructor(ctx) {
20
+ /**
21
+ * The context of the instance. Either a DOM element, an array of DOM
22
+ * elements, a NodeList or a selector
23
+ * @type {HTMLElement|HTMLElement[]|NodeList|string}
24
+ * @access protected
25
+ */
26
+ this.ctx = ctx;
27
+ /**
28
+ * The array of node names which must be excluded from search
29
+ * @type {array}
30
+ * @access protected
31
+ */
32
+ this.nodeNames = ['script', 'style', 'title', 'head', 'html'];
33
+ }
34
+
35
+ /**
36
+ * @typedef Mark~commonOptions
37
+ * @type {object.<string>}
38
+ * @property {object} [window] - A window object
39
+ * @property {Highlight} [highlight] - A Highlight object
40
+ * @property {string} [element="mark"] - HTML element tag name
41
+ * @property {string} [className] - An optional class name
42
+ * @property {string[]} [exclude] - An array with exclusion selectors.
43
+ * Elements matching those selectors will be ignored
44
+ * @property {boolean} [iframes=false] - Whether to search inside iframes
45
+ * @property {number} [iframesTimeout=5000] - Maximum ms to wait for a load
46
+ * event of an iframe
47
+ * @property {boolean} [acrossElements=false] - Whether to find matches across HTML elements.
48
+ * By default, only matches within single HTML elements will be found
49
+ * @property {Mark~markEachCallback} [each]
50
+ * @property {Mark~markNoMatchCallback} [noMatch]
51
+ * @property {Mark~commonDoneCallback} [done]
52
+ * @property {boolean} [debug=false] - Whether to log messages
53
+ * @property {object} [log=window.console] - Where to log messages (only if debug is true)
54
+ */
55
+
56
+ /**
57
+ * Options defined by the user. They will be initialized from one of the
58
+ * public methods. See {@link Mark#mark}, {@link Mark#markRegExp},
59
+ * {@link Mark#markRanges} and {@link Mark#unmark} for option properties.
60
+ * @type {object}
61
+ * @param {object} [val] - An object that will be merged with defaults
62
+ * @access protected
63
+ */
64
+ set opt(val) {
65
+ if ( !(val && val.window && val.window.document) && typeof window === 'undefined') {
66
+ throw new Error('Mark.js: please provide a window object as an option.');
67
+ }
68
+
69
+ const win = val && val.window || window,
70
+ // eslint-disable-next-line
71
+ highlight = val && val.highlight && val.highlight instanceof Highlight;
72
+
73
+ this._opt = Object.assign({}, {
74
+ 'window': win,
75
+ 'element': '',
76
+ 'className': '',
77
+ 'exclude': [],
78
+ 'iframes': false,
79
+ 'iframesTimeout': 5000,
80
+ 'separateWordSearch': true,
81
+ 'staticRanges': true,
82
+ 'rangeAcrossElements': true,
83
+ 'acrossElements': false,
84
+ 'ignoreGroups': 0,
85
+ 'each': () => {},
86
+ 'noMatch': () => {},
87
+ 'filter': () => true,
88
+ 'done': () => {},
89
+ 'debug': false,
90
+ 'log': win.console
91
+ }, val);
92
+
93
+ if ( !this._opt.element) {
94
+ this._opt.element = 'mark';
95
+ }
96
+ // shortens a lengthy name
97
+ this.filter = win.NodeFilter;
98
+ // this empty text node used to simplify code
99
+ this.empty = win.document.createTextNode('');
100
+
101
+ if ( !this._opt.highlightName) {
102
+ this._opt.highlightName = 'advanced-markjs';
103
+ }
104
+
105
+ if (highlight) {
106
+ this.rangeArray = [];
107
+ } else {
108
+ this._opt.highlight = null;
109
+ }
110
+ }
111
+
112
+ get opt() {
113
+ return this._opt;
114
+ }
115
+
116
+ /**
117
+ * An instance of DOMIterator
118
+ * @type {DOMIterator}
119
+ * @access protected
120
+ */
121
+ get iterator() {
122
+ // always return new instance in case there were option changes
123
+ return new DOMIterator(this.ctx, this.opt);
124
+ }
125
+
126
+ /**
127
+ * Logs a message if log is enabled
128
+ * @param {string} msg - The message to log
129
+ * @param {string} [level="debug"] - The log level, e.g. <code>warn</code>
130
+ * <code>error</code>, <code>debug</code>
131
+ * @access protected
132
+ */
133
+ log(msg, level = 'debug') {
134
+ if (this.opt.debug) {
135
+ const log = this.opt.log;
136
+ if (typeof log === 'object' && typeof log[level] === 'function') {
137
+ log[level](`mark.js: ${msg}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * @typedef Mark~logObject
144
+ * @type {object}
145
+ * @property {string} message - The message
146
+ * @property {object} obj - The object
147
+ */
148
+
149
+ /**
150
+ * Logs errors and info
151
+ * @param {array} array - The array of objects
152
+ */
153
+ report(array) {
154
+ array.forEach(item => {
155
+ this.log(`${item.text} ${JSON.stringify(item.obj)}`, item.level || 'debug');
156
+ if ( !item.skip) {
157
+ this.opt.noMatch(item.obj);
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Splits string into separate words if 'separateWordSearch' option has value 'true' but,
164
+ * if it has string value 'preserveTerms', prevents splitting terms surrounding by double quotes.
165
+ * Removes duplicate or empty entries and sort by the length in descending order.
166
+ * It also initializes termStats object.
167
+ * @param {string|string[]} sv - Search value, either a string or an array of strings
168
+ * @return {object}
169
+ * @access protected
170
+ */
171
+ getSeachTerms(sv) {
172
+ const search = typeof sv === 'string' ? [sv] : sv,
173
+ separate = this.opt.separateWordSearch,
174
+ array = [],
175
+ termStats = {},
176
+ split = str => {
177
+ str.split(/ +/).forEach(word => add(word));
178
+ },
179
+ add = str => {
180
+ if (str.trim() && !array.includes(str)) {
181
+ array.push(str);
182
+ // initializes term property
183
+ termStats[str] = 0;
184
+ }
185
+ };
186
+
187
+ search.forEach(str => {
188
+ if (separate) {
189
+ if (separate === 'preserveTerms') {
190
+ // allows highlight quoted terms no matter how many quotes it contains on each side,
191
+ // e.g. ' ""term"" ' or ' """"term" '
192
+ str.split(/"("*[^"]+"*)"/).forEach((term, i) => {
193
+ if (i % 2 > 0) add(term);
194
+ else split(term);
195
+ });
196
+ } else {
197
+ split(str);
198
+ }
199
+ } else {
200
+ add(str);
201
+ }
202
+ });
203
+ array.sort((a, b) => b.length - a.length);
204
+ return { terms: array, termStats };
205
+ }
206
+
207
+ /**
208
+ * Check if a value is a number
209
+ * @param {number|string} value - the value to check;
210
+ * numeric strings allowed
211
+ * @return {boolean}
212
+ * @access protected
213
+ */
214
+ isNumeric(value) {
215
+ // http://stackoverflow.com/a/16655847/145346
216
+ // eslint-disable-next-line eqeqeq
217
+ return Number(parseFloat(value)) == value;
218
+ }
219
+
220
+ /**
221
+ * Filters valid ranges, sorts and, if wrapAllRanges option is false, filters out nesting/overlapping ranges
222
+ * @param {Mark~setOfRanges} array - unprocessed raw array
223
+ * @param {Mark~logObject} logs - The array of logs objects
224
+ * @return {Mark~setOfRanges} - processed array with any invalid entries removed
225
+ * @access protected
226
+ */
227
+ checkRanges(array, logs, min, max) {
228
+ // a range object must have the start and length properties with numeric values
229
+ // [{start: 0, length: 5}, ..]
230
+ const level = 'error';
231
+
232
+ // filters and sorts valid ranges
233
+ const ranges = array.filter(range => {
234
+ if (this.isNumeric(range.start) && this.isNumeric(range.length)) {
235
+ range.start = parseInt(range.start);
236
+ range.length = parseInt(range.length);
237
+
238
+ if (range.start >= min && range.start < max && range.length > 0) {
239
+ return true;
240
+ }
241
+ }
242
+ logs.push({ text: 'Invalid range: ', obj: range, level });
243
+ return false;
244
+ }).sort((a, b) => a.start - b.start);
245
+
246
+ if (this.opt.wrapAllRanges) {
247
+ return ranges;
248
+ }
249
+
250
+ let lastIndex = 0, index;
251
+ // filters out nesting/overlapping ranges
252
+ return ranges.filter(range => {
253
+ index = range.start + range.length;
254
+
255
+ if (range.start >= lastIndex) {
256
+ lastIndex = index;
257
+ return true;
258
+ }
259
+ logs.push({ text: (index < lastIndex ? 'Nest' : 'Overlapp') + 'ing range: ', obj: range, level });
260
+ return false;
261
+ });
262
+ }
263
+
264
+ /**
265
+ * @typedef Mark~blockElementsBoundaryObject
266
+ * @type {object}
267
+ * @property {array} [tagNames] - The array of custom tag names
268
+ * @property {boolean} [extend] - Whether to extend the default boundary elements with custom elements
269
+ * or set only custom elements to boundary type
270
+ * @property {string} [char] - The custom separating char
271
+ */
272
+ /**
273
+ * Sets type: 1 - separate by space, 2 - separate by boundary char with space(s)
274
+ * @param {object} tags - The object containing HTML element tag names
275
+ */
276
+ setType(tags, boundary) {
277
+ const custom = Array.isArray(boundary.tagNames) && boundary.tagNames.length;
278
+
279
+ if (custom) {
280
+ // normalizes custom elements names and adds to the tags object with boundary type value
281
+ boundary.tagNames.forEach(name => tags[name.toLowerCase()] = 2);
282
+ }
283
+ // if not extend, the only custom tag names are set to a boundary type
284
+ if ( !custom || boundary.extend) {
285
+ // sets all tags value to the boundary
286
+ for (const key in tags) {
287
+ tags[key] = 2;
288
+ }
289
+ }
290
+ tags['br'] = 3;
291
+ }
292
+
293
+ /**
294
+ * @typedef Mark~nodeInfoAcross
295
+ * @property {Text} node - The DOM text node
296
+ * @property {number} start - The start index within the composite string
297
+ * @property {number} end - The end index within the composite string
298
+ * @property {number} offset - The offset is used to correct position if a space or string
299
+ * was added to end of composite string after this node textContent
300
+ */
301
+
302
+ /**
303
+ * @typedef Mark~getTextNodesAcrossDict
304
+ * @type {object.<string>}
305
+ * @property {string} text - The composite string of all text nodes
306
+ * @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
307
+ * @property {number} lastIndex - The property used to store the nodes last index
308
+ */
309
+
310
+ /**
311
+ * Callback
312
+ * @callback Mark~getTextNodesAcrossCallback
313
+ * @param {Mark~getTextNodesAcrossDict}
314
+ */
315
+ /**
316
+ * Calls the callback with an object containing all text nodes (including iframe text nodes)
317
+ * with start and end positions and the composite value of them (string)
318
+ * @param {Mark~getTextNodesAcrossCallback} cb - Callback
319
+ * @access protected
320
+ */
321
+ getTextNodesAcross(cb) {
322
+ // a space or string can be safely added to the end of a text node when two text nodes
323
+ // are 'separated' by element with one of these names
324
+ const tags = { div: 1, p: 1, li: 1, td: 1, tr: 1, th: 1, ul: 1,
325
+ ol: 1, dd: 1, dl: 1, dt: 1, h1: 1, h2: 1, h3: 1, h4: 1,
326
+ h5: 1, h6: 1, hr: 1, blockquote: 1, figcaption: 1, figure: 1,
327
+ pre: 1, table: 1, thead: 1, tbody: 1, tfoot: 1, input: 1,
328
+ img: 1, nav: 1, details: 1, label: 1, form: 1, select: 1, menu: 1,
329
+ br: 3, menuitem: 1,
330
+ main: 1, section: 1, article: 1, aside: 1, picture: 1, output: 1,
331
+ button: 1, header: 1, footer: 1, address: 1, area: 1, canvas: 1,
332
+ map: 1, fieldset: 1, textarea: 1, track: 1, video: 1, audio: 1,
333
+ body: 1, iframe: 1, meter: 1, object: 1, svg: 1 };
334
+
335
+ const nodes = [],
336
+ boundary = this.opt.blockElementsBoundary,
337
+ priorityType = boundary ? 2 : 1;
338
+
339
+ let ch = '\x01', tempType, type, prevNode;
340
+
341
+ if (boundary) {
342
+ this.setType(tags, boundary);
343
+
344
+ if (boundary.char) {
345
+ ch = boundary.char.charAt(0);
346
+ }
347
+ }
348
+
349
+ const obj = {
350
+ text: '', regex: /\s/, tags: tags,
351
+ boundary: boundary, str: '', ch: ch
352
+ };
353
+
354
+ this.iterator.forEachNode(this.filter.SHOW_ELEMENT | this.filter.SHOW_TEXT, node => { // each
355
+ if (prevNode) {
356
+ nodes.push(this.getNodeInfo(prevNode, node, type, obj));
357
+ }
358
+ type = null;
359
+ prevNode = node;
360
+
361
+ }, node => { // filter
362
+ if (node.nodeType === 1) { // element
363
+ tempType = tags[node.nodeName.toLowerCase()];
364
+
365
+ if (tempType === 3) { // br element
366
+ obj.str += '\n';
367
+ }
368
+
369
+ if ( !type || tempType === priorityType) {
370
+ type = tempType;
371
+ }
372
+ return false;
373
+ }
374
+ return !this.excluded(node.parentNode);
375
+
376
+ }, () => { // done
377
+ // processes the last node
378
+ if (prevNode) {
379
+ nodes.push(this.getNodeInfo(prevNode, null, type, obj));
380
+ }
381
+ cb({
382
+ text: obj.text,
383
+ nodes: nodes,
384
+ lastIndex: 0
385
+ });
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Creates object
391
+ * @param {Text} prevNode - The previous DOM text node
392
+ * @param {Text} node - The current DOM text node
393
+ * @param {number|null} type - define how to separate the previous and current text nodes textContent;
394
+ * type is null when nodes doesn't separated by block elements
395
+ * @param {object} obj - The auxiliary object to pass multiple parameters to the method
396
+ */
397
+ getNodeInfo(prevNode, node, type, obj) {
398
+ const start = obj.text.length,
399
+ ch = obj.ch;
400
+ let offset = 0,
401
+ str = obj.str,
402
+ text = prevNode.textContent;
403
+
404
+ if (node) {
405
+ const startBySpace = obj.regex.test(node.textContent[0]),
406
+ both = startBySpace && obj.regex.test(text[text.length - 1]);
407
+
408
+ if (obj.boundary || !both) {
409
+ let separate = type;
410
+ // searches for the first parent of the previous text node that met condition
411
+ // and checks does they have the same parent or the parent contains the current text node
412
+ if (!type) {
413
+ let parent = prevNode.parentNode;
414
+ while (parent) {
415
+ type = obj.tags[parent.nodeName.toLowerCase()];
416
+ if (type) {
417
+ separate = !(parent === node.parentNode || parent.contains(node));
418
+ break;
419
+ }
420
+ parent = parent.parentNode;
421
+ }
422
+ }
423
+
424
+ if (separate) {
425
+ if ( !both) {
426
+ str += type === 1 ? ' ' : type === 2 ? ' ' + ch + ' ' : '';
427
+
428
+ } else if (type === 2) {
429
+ str += both ? ch : startBySpace ? ' ' + ch : ch + ' ';
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ if (str) {
436
+ text += str;
437
+ offset = str.length;
438
+ obj.str = '';
439
+ }
440
+ obj.text += text;
441
+
442
+ return this.createInfo(prevNode, start, obj.text.length - offset, offset);
443
+ }
444
+
445
+ /**
446
+ * @typedef Mark~nodeInfo
447
+ * @property {Text} node - The DOM text node
448
+ * @property {number} start - The start index within the composite string
449
+ * @property {number} end - The end index within the composite string
450
+ * @property {number} offset - This property is required for compatibility with [Mark~nodeInfoAcross]
451
+ * for {@link Mark#markRanges}
452
+ */
453
+
454
+ /**
455
+ * @typedef Mark~getTextNodesDict
456
+ * @type {object.<string>}
457
+ * @property {string} text - The composite value of all text nodes
458
+ * @property {Mark~nodeInfo[]} nodes - The array of objects
459
+ * @property {number} lastIndex - The property used to store the nodes last index
460
+ */
461
+
462
+ /**
463
+ * Callback
464
+ * @callback Mark~getTextNodesCallback
465
+ * @param {Mark~getTextNodesDict}
466
+ */
467
+ /**
468
+ * Calls the callback with an object containing all text nodes (including iframe text nodes)
469
+ * with start and end positions and the composite value of them (string)
470
+ * @param {Mark~getTextNodesCallback} cb - Callback
471
+ * @access protected
472
+ */
473
+ getRangesTextNodes(cb, lines) {
474
+ const nodes = [],
475
+ regex = /\n/g,
476
+ newLines = [0],
477
+ show = this.filter.SHOW_TEXT | (lines ? this.filter.SHOW_ELEMENT : 0);
478
+ let text = '',
479
+ len = 0,
480
+ rm;
481
+
482
+ this.iterator.forEachNode(show, node => { // each
483
+ if (lines) {
484
+ while ((rm = regex.exec(node.textContent)) !== null) {
485
+ newLines.push(len + rm.index);
486
+ }
487
+ }
488
+ text += node.textContent;
489
+
490
+ nodes.push({
491
+ start: len,
492
+ end: (len = text.length),
493
+ offset: 0,
494
+ node: node
495
+ });
496
+
497
+ }, node => { // filter
498
+ if (lines && node.nodeType === 1) {
499
+ if (node.tagName.toLowerCase() === 'br') {
500
+ newLines.push(len);
501
+ }
502
+ return false;
503
+ }
504
+ return !this.excluded(node.parentNode);
505
+
506
+ }, () => { // done
507
+ const dict = { text, nodes, lastIndex: 0 };
508
+
509
+ if (lines) {
510
+ newLines.push(len);
511
+ dict.newLines = newLines;
512
+ }
513
+ cb(dict);
514
+ });
515
+ }
516
+
517
+ /**
518
+ * @typedef Mark~nodeInfo
519
+ * @property {Text} node - The DOM text node
520
+ * @property {number} start - The start index within the composite string
521
+ */
522
+
523
+ /**
524
+ * @typedef Mark~getTextNodesDict
525
+ * @type {object.<string>}
526
+ * @property {Mark~nodeInfo[]} nodes - The array of objects
527
+ * @property {number} lastIndex - The property used to store the nodes last index
528
+ */
529
+
530
+ /**
531
+ * Callback
532
+ * @callback Mark~getTextNodesCallback
533
+ * @param {Mark~getTextNodesDict}
534
+ */
535
+ /**
536
+ * Calls the callback with an object containing all text nodes (including iframe text nodes)
537
+ * @param {Mark~getTextNodesCallback} cb - Callback
538
+ * @access protected
539
+ */
540
+ getTextNodes(cb) {
541
+ const nodes = [];
542
+ let start = 0;
543
+
544
+ this.iterator.forEachNode(this.filter.SHOW_TEXT, node => { // each
545
+ nodes.push({
546
+ node,
547
+ start
548
+ });
549
+ start += node.textContent.length;
550
+
551
+ }, node => { // filter
552
+ return !this.excluded(node.parentNode);
553
+
554
+ }, () => { // done
555
+ cb({ nodes, lastIndex: 0 });
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Checks if an element matches any of the specified exclude selectors.
561
+ * @param {HTMLElement} elem - The element to check
562
+ * @return {boolean}
563
+ * @access protected
564
+ */
565
+ excluded(elem) {
566
+ // it's faster to check if an array contains the node name than a selector in 'DOMIterator.matches()'
567
+ // also it allows using a string of selectors instead of an array with the 'exclude' option
568
+ return this.nodeNames.includes(elem.nodeName.toLowerCase()) || DOMIterator.matches(elem, this.opt.exclude);
569
+ }
570
+
571
+ /**
572
+ * Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
573
+ * Creates info object(s) related to the newly created node(s) and inserts into dict.nodes or replace an existing one
574
+ * It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
575
+ * @param {Mark~wrapRangeInsertDict} dict - The dictionary
576
+ * @param {object} n - The currently processed info object
577
+ * @param {number} s - The position where to start wrapping
578
+ * @param {number} e - The position where to end wrapping
579
+ * @param {number} start - The start position of the match
580
+ * @param {number} index - The current index of the processed object
581
+ * @return {object} Returns object containing the mark element, the splitted text node
582
+ * that will appear after the wrapped text node, and increment number
583
+ */
584
+ wrapRangeInsert(dict, n, s, e, start, index) {
585
+ const ended = e === n.node.textContent.length,
586
+ end = n.end;
587
+ // type: 0 - whole text node, 1 - from the start, 2 - to the end, 3 - between
588
+ let type = 1,
589
+ splitIndex = e,
590
+ node = n.node;
591
+ // prevents creating empty sibling text nodes at the start/end of a text node
592
+ if (s !== 0) {
593
+ node = node.splitText(s);
594
+ splitIndex = e - s;
595
+ type = ended ? 2 : 3;
596
+
597
+ } else if (ended) { // whole
598
+ type = 0;
599
+ }
600
+
601
+ const retNode = ended ? this.empty : node.splitText(splitIndex),
602
+ mark = this.createElement(node),
603
+ markChild = mark.childNodes[0],
604
+ nodeInfo = this.createInfo(retNode, type === 0 || type === 2 ? end : n.start + e, end, n.offset);
605
+
606
+ if (type === 0) {
607
+ n.node = markChild;
608
+ return { mark, nodeInfo, increment: 0 };
609
+ }
610
+
611
+ const info = this.createInfo(markChild, type === 1 ? n.start : start, n.start + e, 0);
612
+ // inserts new node(s) info in dict.nodes depending where a range is located in a text node
613
+ if (type === 1) {
614
+ dict.nodes.splice(index, 1, info, nodeInfo);
615
+ } else {
616
+ if (type === 2) {
617
+ dict.nodes.splice(index + 1, 0, info);
618
+ } else {
619
+ dict.nodes.splice(index + 1, 0, info, nodeInfo);
620
+ }
621
+ n.end = start;
622
+ n.offset = 0;
623
+ }
624
+ return { mark, nodeInfo, increment: type < 3 ? 1 : 2 };
625
+ }
626
+
627
+ /**
628
+ * Creates object
629
+ * @param {Text} node - The DOM text node
630
+ * @param {number} start - The position where to start wrapping
631
+ * @param {number} end - The position where to end wrapping
632
+ * @param {number} offset - The length of space/string that is added to end of composite string
633
+ * after this node textContent
634
+ */
635
+ createInfo(node, start, end, offset) {
636
+ return { node, start, end, offset };
637
+ }
638
+
639
+ /**
640
+ * Each callback
641
+ * @callback Mark~wrapRangeEachCallback
642
+ * @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
643
+ */
644
+ /**
645
+ * Splits the text node into two or three nodes and wraps the necessary node or wraps the input node
646
+ * It doesn't create empty sibling text nodes when `Text.splitText()` method splits a text node at the start/end
647
+ * @param {Text} node - The DOM text node
648
+ * @param {number} start - The position where to start wrapping
649
+ * @param {number} end - The position where to end wrapping
650
+ * @param {Mark~wrapRangeEachCallback} eachCb - Each callback
651
+ * @return {Text}
652
+ * @access protected
653
+ */
654
+ wrapRange(n, start, end, eachCb) {
655
+ let node = n.node,
656
+ retNode;
657
+
658
+ if (this.rangeArray) {
659
+ this.createRange(node, start, node, end, n.start + start, eachCb);
660
+ retNode = node;
661
+
662
+ } else {
663
+ let ended = end === node.textContent.length,
664
+ index = end;
665
+
666
+ if (start !== 0) {
667
+ node = node.splitText(start);
668
+ index = end - start;
669
+ }
670
+ retNode = ended ? this.empty : node.splitText(index);
671
+ eachCb(this.createElement(node));
672
+ }
673
+ return retNode;
674
+ }
675
+
676
+ /**
677
+ * Each callback
678
+ * @callback Mark~createRangeEachCallback
679
+ * @param {StaticRange|Range} range - The created range
680
+ * @param {boolean} true - Required only for across elements code with the rangeAcrossElements option
681
+ */
682
+ /**
683
+ * Creates the new StaticRange/Range object with the specified parameters
684
+ * @param {Text} startNode - The text node where a match is started
685
+ * @param {number} startOffset - The start index of the match in startNode
686
+ * @param {number} endNode - The text node where a match is ended
687
+ * @param {number} endOffset - The end index of the match in endNode
688
+ * @param {number} absoluteOffset - The absolute start index from the beginning of first context.
689
+ * Uses to sort ranges by ascending order.
690
+ * @param {Mark~createRangeEachCallback} eachCb - Each callback
691
+ */
692
+ createRange(startNode, startOffset, endNode, endOffset, absoluteOffset, eachCb) {
693
+ let range;
694
+
695
+ if (this.opt.staticRanges) {
696
+ range = new StaticRange({ startContainer: startNode, startOffset, endContainer: endNode, endOffset });
697
+
698
+ } else {
699
+ range = new Range();
700
+ range.setStart(startNode, startOffset);
701
+ range.setEnd(endNode, endOffset);
702
+ }
703
+
704
+ range.absoluteOffset = absoluteOffset;
705
+
706
+ eachCb(range, true);
707
+ // a range can be destroyed on the 'each' callback
708
+ if (range) this.rangeArray.push(range);
709
+ }
710
+
711
+ /**
712
+ * Wraps the new element with the necessary attributes around text node
713
+ * @param {Text} node - The DOM text node
714
+ * @return {HTMLElement} Returns the created DOM node
715
+ */
716
+ createElement(node) {
717
+ let markNode = this.opt.window.document.createElement(this.opt.element);
718
+ markNode.setAttribute('data-markjs', 'true');
719
+
720
+ if (this.opt.className) {
721
+ markNode.setAttribute('class', this.opt.className);
722
+ }
723
+ markNode.textContent = node.textContent;
724
+ node.parentNode.replaceChild(markNode, node);
725
+
726
+ return markNode;
727
+ }
728
+
729
+ /**
730
+ * @typedef Mark~wrapRangeAcrossDict
731
+ * @type {object.<string>}
732
+ * @property {string} text - The composite string of all text nodes
733
+ * @property {Mark~nodeInfoAcross[]} nodes - An array of node info objects
734
+ * @property {number} lastIndex - The property used to store the nodes last index
735
+ */
736
+ /**
737
+ * Each callback
738
+ * @callback Mark~wrapRangeAcrossEachCallback
739
+ * @param {HTMLElement|StaticRange|Range} node - The wrapped DOM element or range (Highlight API)
740
+ * @param {boolean} rangeStart - Indicate the start of the current range or always true (Highlight API)
741
+ */
742
+
743
+ /**
744
+ * Filter callback
745
+ * @callback Mark~wrapRangeAcrossFilterCallback
746
+ * @param {Text|Text[]} node - The current text node or an array of text nodes when using the Highlight API
747
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
748
+ */
749
+ /**
750
+ * Determines matches by start and end positions using the text node dictionary
751
+ * and calls {@link Mark#wrapRange} or {@link Mark#wrapRangeInsert} to wrap them
752
+ * @param {Mark~wrapRangeAcrossDict} dict - The dictionary
753
+ * @param {number} start - The start index of the match
754
+ * @param {number} end - The end index of the match
755
+ * @param {Mark~wrapRangeAcrossFilterCallback} filterCb - Filter callback
756
+ * @param {Mark~wrapRangeAcrossEachCallback} eachCb - Each callback
757
+ * @access protected
758
+ */
759
+ wrapRangeAcross(dict, start, end, filterCb, eachCb) {
760
+ // dict.lastIndex stores the last node index to avoid iteration from the beginning
761
+ let i = dict.lastIndex,
762
+ rangeStart = true,
763
+ startInfo,
764
+ filterNodes = [],
765
+ e;
766
+ const wrapAllRanges = this.opt.wrapAllRanges,
767
+ highlightAPI = !!this.opt.highlight, // when using the Highlight API, no text nodes are split
768
+ singleRange = highlightAPI && this.opt.rangeAcrossElements;
769
+
770
+ if (wrapAllRanges) {
771
+ // finds the start index in case of nesting/overlapping
772
+ while (i > 0 && dict.nodes[i].start > start) {
773
+ i--;
774
+ }
775
+ }
776
+
777
+ for (i; i < dict.nodes.length; i++) {
778
+ if (i + 1 === dict.nodes.length || dict.nodes[i+1].start > start) {
779
+ let n = dict.nodes[i];
780
+
781
+ if (singleRange) {
782
+ filterNodes.push(n.node);
783
+
784
+ } else if ( !filterCb(n.node)) {
785
+ break;
786
+ }
787
+ // map range from dict.text to text node
788
+ const s = start - n.start;
789
+ e = (end > n.end ? n.end : end) - n.start;
790
+
791
+ // prevents creating an empty mark node, prevents exception if something went wrong, useful for debug
792
+ if (s >= 0 && e > s) {
793
+ if (singleRange) {
794
+ if (rangeStart) {
795
+ startInfo = [n.node, s, n.start + s];
796
+ }
797
+
798
+ } else if ( !highlightAPI && wrapAllRanges) {
799
+ const obj = this.wrapRangeInsert(dict, n, s, e, start, i);
800
+ n = obj.nodeInfo;
801
+ eachCb(obj.mark, rangeStart);
802
+
803
+ } else {
804
+ // when using the Highlight API and the 'rangeAcrossElements: false',
805
+ // it creates multiple ranges for matches that are located across elements
806
+ n.node = this.wrapRange(n, s, e, elemOrRange => {
807
+ eachCb(elemOrRange, rangeStart);
808
+ });
809
+ // sets the new text node start index in the case of subsequent matches in the same text node
810
+ if ( !highlightAPI) n.start += e;
811
+ }
812
+ rangeStart = false;
813
+ }
814
+
815
+ if (end > n.end) {
816
+ // the range extends to the next text node
817
+ start = n.end + n.offset;
818
+
819
+ } else {
820
+ // creates a single StaticRange/Range for matches that are located across elements
821
+ if (startInfo && filterCb(filterNodes)) {
822
+ this.createRange(startInfo[0], startInfo[1], n.node, e, startInfo[2], eachCb);
823
+ }
824
+ break;
825
+ }
826
+ }
827
+ }
828
+ // sets the last index
829
+ dict.lastIndex = i;
830
+ }
831
+
832
+ /**
833
+ * Filter callback before each wrapping
834
+ * @callback Mark~wrapGroupsFilterCallback
835
+ * @param {Text} node - The text node where the match occurs
836
+ * @param {string} group - The current group matching string
837
+ * @param {number} i - The current group index
838
+ */
839
+ /**
840
+ * Callback for each wrapped element
841
+ * @callback Mark~wrapGroupsEachCallback
842
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
843
+ * @param {number} i - The current group index
844
+ */
845
+
846
+ /**
847
+ * Wraps match groups with RegExp.hasIndices
848
+ * @param {Text} node - The text node where the match occurs
849
+ * @param {array} match - The result of RegExp exec() method
850
+ * @param {RegExp} regex - The regular expression
851
+ * @param {Mark~wrapGroupsFilterCallback} filterCb - Filter callback
852
+ * @param {Mark~wrapGroupsEachCallback} eachCb - Each callback
853
+ */
854
+ wrapGroups(n, match, regex, filterCb, eachCb) {
855
+ let lastIndex = 0,
856
+ offset = 0,
857
+ i = 0,
858
+ highlightAPI = this.opt.highlight,
859
+ isWrapped = false,
860
+ group, start, end = 0;
861
+
862
+ while (++i < match.length) {
863
+ group = match[i];
864
+
865
+ if (group) {
866
+ start = match.indices[i][0];
867
+ //it prevents marking nested group - parent group is already marked
868
+ if (start >= lastIndex) {
869
+ end = match.indices[i][1];
870
+
871
+ if (filterCb(n.node, group, i)) {
872
+ // when a group is wrapping, a text node is split at the end index,
873
+ // so to correct the start & end indexes of a new text node, subtract
874
+ // the end index of the last wrapped group (offset)
875
+ n.node = this.wrapRange(n, start - offset, end - offset, elemOrRange => { // each
876
+ eachCb(elemOrRange);
877
+ });
878
+
879
+ if (end > lastIndex) {
880
+ lastIndex = end;
881
+ }
882
+ // when using the Highlight API, no text nodes are split
883
+ if ( !highlightAPI) offset = end;
884
+ isWrapped = true;
885
+ }
886
+ }
887
+ }
888
+ }
889
+ // resets the lastIndex when at least one group is wrapped (prevents infinite loop)
890
+ if (isWrapped) {
891
+ if ( !highlightAPI) regex.lastIndex = 0;
892
+
893
+ } else if (match[0].length === 0) {
894
+ this.setLastIndex(regex, end);
895
+ }
896
+ }
897
+
898
+ /**
899
+ * Filter callback before each wrapping
900
+ * @callback Mark~wrapGroupsAcrossFilterCallback
901
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
902
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
903
+ * @param {string} group - The current group matching string
904
+ * @param {number} i - The current group index
905
+ */
906
+ /**
907
+ * Callback for each wrapped element
908
+ * @callback Mark~wrapGroupsAcrossEachCallback
909
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
910
+ * @param {number} i - The current group index
911
+ * @param {boolean} groupStart - Indicate the start of a group
912
+ */
913
+
914
+ /**
915
+ * Wraps match groups with RegExp.hasIndices across elements
916
+ * @param {Mark~wrapGroupsAcrossDict} dict - The dictionary
917
+ * @param {array} match - The result of RegExp exec() method
918
+ * @param {RegExp} regex - The regular expression
919
+ * @param {Mark~wrapGroupsAcrossFilterCallback} filterCb - Filter callback
920
+ * @param {Mark~wrapGroupsAcrossEachCallback} eachCb - Each callback
921
+ */
922
+ wrapGroupsAcross(dict, match, regex, filterCb, eachCb) {
923
+ let lastIndex = 0,
924
+ i = 0,
925
+ end = 0,
926
+ start,
927
+ group,
928
+ isWrapped;
929
+
930
+ while (++i < match.length) {
931
+ group = match[i];
932
+
933
+ if (group) {
934
+ start = match.indices[i][0];
935
+ // the wrapAllRanges option allows wrapping nested group(s),
936
+ // the 'start >= lastIndex' prevents wrapping nested group(s) - the parent group is already wrapped
937
+ if (this.opt.wrapAllRanges || start >= lastIndex) {
938
+ end = match.indices[i][1];
939
+ isWrapped = false;
940
+
941
+ this.wrapRangeAcross(dict, start, end, nodeOrArray => { // filter
942
+ return filterCb(nodeOrArray, group, i);
943
+
944
+ }, (elemOrRange, groupStart) => { // each
945
+ isWrapped = true;
946
+ eachCb(elemOrRange, groupStart);
947
+ });
948
+ // group may be filtered out
949
+ if (isWrapped && end > lastIndex) {
950
+ lastIndex = end;
951
+ }
952
+ }
953
+ }
954
+ }
955
+
956
+ if (match[0].length === 0) {
957
+ this.setLastIndex(regex, end);
958
+ }
959
+ }
960
+
961
+ /**
962
+ * When the length of a match is zero, there is a need to set the RegExp lastIndex depending on conditions.
963
+ * It's necessary to avoid infinite loop and set position from which to start the next match
964
+ * @param {RegExp} regex - The regular expression
965
+ * @param {number} end - The end index of the last processed group
966
+ */
967
+ setLastIndex(regex, end) {
968
+ const index = regex.lastIndex;
969
+ // end > index - case when a capturing group is inside positive lookahead assertion
970
+ // end > 0 - case when a match is filtered out or a capturing group is inside positive lookbehind assertion
971
+ regex.lastIndex = end > index ? end : end > 0 ? index + 1 : Infinity;
972
+ }
973
+
974
+ /**
975
+ * @typedef Mark~filterInfoObject
976
+ * @type {object}
977
+ * @property {array} match - The result of RegExp exec() method
978
+ * @property {boolean} matchStart - Indicate the start of match. It's only available
979
+ * with the 'acrossElements' option
980
+ * @property {number} groupIndex - The group index. It's only available
981
+ * with 'separateGroups' option
982
+ * @property {object} execution - The helper object for early abort. Contains
983
+ * boolean 'abort' property.
984
+ */
985
+ /**
986
+ * @typedef Mark~eachInfoObject
987
+ * @type {object}
988
+ * @property {array} match - The result of RegExp exec() method
989
+ * @property {boolean} matchStart - Indicate the start of match. It's only available
990
+ * with the 'acrossElements' option
991
+ * @property {number} count - The current number of matches
992
+ * @property {number} groupIndex - The index of current match group. It's only
993
+ * available with 'separateGroups' option
994
+ * @property {boolean} groupStart - Indicate the start of group. It's only
995
+ * available with both 'acrossElements' and 'separateGroups' options
996
+ */
997
+ /**
998
+ * @typedef Mark~infoObject
999
+ * @type {object}
1000
+ * @property {number} count - The number of matches so far
1001
+ * @property {object} execution - The helper object for early abort. Contains boolean 'abort' property.
1002
+ */
1003
+
1004
+ /**
1005
+ * Group filter callback before each wrapping
1006
+ * @callback Mark~processGroupsFilterCallback
1007
+ * @param {Text} node - The text node where the match occurs
1008
+ * @param {string} group - The matching string of the current group
1009
+ * @param {Mark~filterInfoObject} info - The object containing the match information
1010
+ */
1011
+ /**
1012
+ * Callback for each wrapped element
1013
+ * @callback Mark~processGroupsEachCallback
1014
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1015
+ * @param {Mark~eachInfoObject} - The object containing the match information
1016
+ */
1017
+ /**
1018
+ * Callback on end
1019
+ * @callback Mark~processGroupsEndCallback
1020
+ * @param {number} count - The number of matches
1021
+ */
1022
+
1023
+ /**
1024
+ * Wraps match capturing groups
1025
+ * @param {RegExp} regex - The regular expression to be searched for
1026
+ * @param {number} unused
1027
+ * @param {Mark~infoObject} info - The object used on filter and each callbacks
1028
+ * @param {Mark~processGroupsFilterCallback} filterCb - Filter callback
1029
+ * @param {Mark~processGroupsEachCallback} eachCb - Each callback
1030
+ * @param {Mark~processGroupsEndCallback} endCb
1031
+ * @access protected
1032
+ */
1033
+ processGroups(regex, unused, info, filterCb, eachCb, endCb) {
1034
+ let count = info.count, match, filterStart, eachStart;
1035
+
1036
+ this.getTextNodes(dict => {
1037
+ dict.nodes.every(n => {
1038
+ while ((match = regex.exec(n.node.textContent)) !== null) {
1039
+ info.match = match;
1040
+ filterStart = eachStart = true;
1041
+
1042
+ this.wrapGroups(n, match, regex, (node, group, grIndex) => { // filter
1043
+ info.matchStart = filterStart;
1044
+ info.groupIndex = grIndex;
1045
+ filterStart = false;
1046
+ return filterCb(node, group, info);
1047
+
1048
+ }, (elemOrRange) => { // each
1049
+ if (eachStart) count++;
1050
+
1051
+ info.count = count;
1052
+ info.matchStart = eachStart;
1053
+ eachStart = false;
1054
+
1055
+ eachCb(elemOrRange, info);
1056
+ });
1057
+
1058
+ if (info.abort) break;
1059
+ }
1060
+ // breaks loop on custom abort
1061
+ return !info.abort;
1062
+ });
1063
+ endCb(count);
1064
+ });
1065
+ }
1066
+
1067
+ /**
1068
+ * Filter callback before each wrapping
1069
+ * @callback Mark~processGroupsAcrossFilterCallback
1070
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1071
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1072
+ * @param {string} group - The matching string of the current group
1073
+ * @param {Mark~filterInfoObject} info - The object containing the match information
1074
+ */
1075
+ /**
1076
+ * Callback for each wrapped element
1077
+ * @callback Mark~processGroupsAcrossEachCallback
1078
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1079
+ * @param {Mark~eachInfoObject} - The object containing the match information
1080
+ */
1081
+ /**
1082
+ * Callback on end
1083
+ * @callback Mark~processGroupsAcrossEndCallback
1084
+ * @param {number} count - The number of all matches
1085
+ */
1086
+ /**
1087
+ * Wraps match capturing groups across elements
1088
+ * @param {RegExp} regex - The regular expression to be searched for
1089
+ * @param {number} unused
1090
+ * @param {Mark~infoObject} info - The object used on filter and each callbacks
1091
+ * @param {Mark~processGroupsAcrossFilterCallback} filterCb - Filter callback
1092
+ * @param {Mark~processGroupsAcrossEachCallback} eachCb - Each callback
1093
+ * @param {Mark~processGroupsAcrossEndCallback} endCb
1094
+ * @access protected
1095
+ */
1096
+ processGroupsAcross(regex, unused, info, filterCb, eachCb, endCb) {
1097
+ let count = info.count, match, filterStart, eachStart;
1098
+
1099
+ this.getTextNodesAcross(dict => {
1100
+ while ((match = regex.exec(dict.text)) !== null) {
1101
+ info.match = match;
1102
+ filterStart = eachStart = true;
1103
+
1104
+ this.wrapGroupsAcross(dict, match, regex, (nodeOrArray, group, grIndex) => { // filter
1105
+ // eslint-disable-next-line
1106
+ info.groupStart = undefined;
1107
+ info.matchStart = filterStart;
1108
+ info.groupIndex = grIndex;
1109
+ filterStart = false;
1110
+ return filterCb(nodeOrArray, group, info);
1111
+
1112
+ }, (elemOrRange, groupStart) => { // each
1113
+ if (eachStart) count++;
1114
+
1115
+ info.count = count;
1116
+ info.matchStart = eachStart;
1117
+ info.groupStart = groupStart;
1118
+ eachCb(elemOrRange, info);
1119
+ eachStart = false;
1120
+ });
1121
+ // breaks loop on custom abort
1122
+ if (info.abort) break;
1123
+ }
1124
+ endCb(count);
1125
+ });
1126
+ }
1127
+
1128
+ /**
1129
+ * Filter callback before each wrapping
1130
+ * @callback Mark~processMatchesFilterCallback
1131
+ * @param {Text} node - The text node where the match occurs
1132
+ * @param {string} str - The matching string
1133
+ * @param {Mark~filterInfoObject} filterInfo - The object containing the match information
1134
+ */
1135
+ /**
1136
+ * Callback for each wrapped element
1137
+ * @callback Mark~processMatchesEachCallback
1138
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1139
+ * @param {Mark~eachInfoObject} eachInfo - The object containing the match information
1140
+ */
1141
+ /**
1142
+ * Callback on end
1143
+ * @callback Mark~processMatchesEndCallback
1144
+ * @param {number} count - The number of all matches
1145
+ */
1146
+
1147
+ /**
1148
+ * Wraps the instance element and class around matches within single HTML elements in all contexts
1149
+ * @param {RegExp} regex - The regular expression to be searched for
1150
+ * @param {number} ignoreGroups - A number of RegExp capturing groups to ignore from the beginning of a match
1151
+ * @param {Mark~infoObject} info - The object used on filter and each callbacks
1152
+ * @param {Mark~processMatchesFilterCallback} filterCb - Filter callback
1153
+ * @param {Mark~processMatchesEachCallback} eachCb - Each callback
1154
+ * @param {Mark~processMatchesEndCallback} endCb
1155
+ * @access protected
1156
+ */
1157
+ processMatches(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
1158
+ const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
1159
+ let count = info.count, match, str;
1160
+
1161
+ this.getTextNodes(dict => {
1162
+ dict.nodes.every(n => {
1163
+ while ((match = regex.exec(n.node.textContent)) !== null) {
1164
+ // prevents an infinite loop
1165
+ if ((str = match[index]) === '') {
1166
+ regex.lastIndex++;
1167
+ continue;
1168
+ }
1169
+ info.match = match;
1170
+
1171
+ if ( !filterCb(n.node, str, info)) {
1172
+ continue;
1173
+ }
1174
+ // calculates the start index inside node.textContent
1175
+ let i = 0, start = match.index;
1176
+ while (++i < index) {
1177
+ if (match[i]) { // allows any ignore group to be undefined
1178
+ start += match[i].length;
1179
+ }
1180
+ }
1181
+
1182
+ n.node = this.wrapRange(n, start, start + str.length, elemOrRange => {
1183
+ info.count = ++count;
1184
+ eachCb(elemOrRange, info);
1185
+ });
1186
+ // when using the Highlight API, no text nodes are split
1187
+ if ( !this.opt.highlight) regex.lastIndex = 0;
1188
+
1189
+ if (info.abort) break;
1190
+ }
1191
+ // breaks loop on custom abort
1192
+ return !info.abort;
1193
+ });
1194
+ endCb(count);
1195
+ });
1196
+ }
1197
+
1198
+ /**
1199
+ * Filter callback before each wrapping
1200
+ * @callback Mark~processMatchesAcrossFilterCallback
1201
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1202
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1203
+ * @param {string} str - The matching string
1204
+ * @param {Mark~filterInfoObject} filterInfo - The object containing the match information
1205
+ */
1206
+ /**
1207
+ * Callback for each wrapped element
1208
+ * @callback Mark~processMatchesAcrossEachCallback
1209
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1210
+ * @param {Mark~eachInfoObject} - The object containing the match information
1211
+ */
1212
+
1213
+ /**
1214
+ * Callback on end
1215
+ * @callback Mark~processMatchesAcrossEndCallback
1216
+ * @param {number} count - The number of all matches
1217
+ */
1218
+ /**
1219
+ * Wraps the instance element and class around matches across all HTML elements in all contexts
1220
+ * @param {RegExp} regex - The regular expression to be searched for
1221
+ * @param {number} ignoreGroups - A number of RegExp capturing groups to ignore from the beginning of a match
1222
+ * @param {Mark~infoObject} info - The object used on filter and each callbacks
1223
+ * @param {Mark~processMatchesAcrossFilterCallback} filterCb - Filter callback
1224
+ * @param {Mark~processMatchesAcrossEachCallback} eachCb - Each callback
1225
+ * @param {Mark~processMatchesAcrossEndCallback} endCb
1226
+ * @access protected
1227
+ */
1228
+ processMatchesAcross(regex, ignoreGroups, info, filterCb, eachCb, endCb) {
1229
+ const index = ignoreGroups === 0 ? 0 : ignoreGroups + 1;
1230
+ let count = info.count, match, str, matchStart;
1231
+
1232
+ this.getTextNodesAcross(dict => {
1233
+ while ((match = regex.exec(dict.text)) !== null) {
1234
+ // prevents an infinite loop
1235
+ if ((str = match[index]) === '') {
1236
+ regex.lastIndex++;
1237
+ continue;
1238
+ }
1239
+ info.match = match;
1240
+ matchStart = true;
1241
+
1242
+ // calculates the start index inside dict.text
1243
+ let i = 0, start = match.index;
1244
+ while (++i < index) {
1245
+ if (match[i]) { // allows any ignore group to be undefined
1246
+ start += match[i].length;
1247
+ }
1248
+ }
1249
+
1250
+ this.wrapRangeAcross(dict, start, start + str.length, nodeOrArray => { // filter
1251
+ info.matchStart = matchStart;
1252
+ matchStart = false;
1253
+ return filterCb(nodeOrArray, str, info);
1254
+
1255
+ }, (elemOrRange, mStart) => { // each
1256
+ if (mStart) count++;
1257
+
1258
+ info.count = count;
1259
+ info.matchStart = mStart;
1260
+ eachCb(elemOrRange, info);
1261
+ });
1262
+
1263
+ if (info.abort) break;
1264
+ }
1265
+ endCb(count);
1266
+ });
1267
+ }
1268
+
1269
+ /**
1270
+ * Callback for each wrapped element
1271
+ * @callback Mark~wrapRangesEachCallback
1272
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1273
+ * @param {Mark~rangeObject} range - the current range object; the start and length values can be
1274
+ * modified if they are not numeric integers
1275
+ * @param {Mark~rangeInfoObject} rangeInfo - The object containing the range information
1276
+ */
1277
+ /**
1278
+ * Filter callback before each wrapping
1279
+ * @callback Mark~wrapRangesFilterCallback
1280
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1281
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1282
+ * @param {Mark~rangeObject} range - the current range object
1283
+ * @param {string} substr - string extracted from the matching range
1284
+ * @param {number} index - The current range index ???
1285
+ */
1286
+
1287
+ /**
1288
+ * Callback on end
1289
+ * @callback Mark~wrapRangesEndCallback
1290
+ * @param {number} count - The number of wrapped ranges
1291
+ * @param {Mark~logObject[]} logs - The array of objects
1292
+ */
1293
+ /**
1294
+ * Wraps the indicated ranges across all HTML elements in all contexts
1295
+ * @param {Mark~setOfRanges} ranges
1296
+ * @param {Mark~wrapRangesFilterCallback} filterCb
1297
+ * @param {Mark~wrapRangesEachCallback} eachCb
1298
+ * @param {Mark~wrapRangesEndCallback} endCb
1299
+ * @access protected
1300
+ */
1301
+ processRanges(ranges, filterCb, eachCb, endCb) {
1302
+ const lines = this.opt.markLines,
1303
+ logs = [],
1304
+ skipped = [],
1305
+ level = 'warn';
1306
+ let count = 0;
1307
+
1308
+ this.getRangesTextNodes(dict => {
1309
+ const max = lines ? dict.newLines.length : dict.text.length,
1310
+ array = this.checkRanges(ranges, logs, lines ? 1 : 0, max);
1311
+
1312
+ array.forEach((range, index) => {
1313
+ let start = range.start,
1314
+ end = start + range.length;
1315
+
1316
+ if (end > max) {
1317
+ // with wrapAllRanges option, there can be several report of limited ranges
1318
+ logs.push({ text: `Range was limited to: ${max}`, obj: range, skip: true, level });
1319
+ end = max;
1320
+ }
1321
+
1322
+ if (lines) {
1323
+ start = dict.newLines[start-1];
1324
+ if (dict.text[start] === '\n') {
1325
+ start++;
1326
+ }
1327
+ end = dict.newLines[end-1];
1328
+ }
1329
+
1330
+ const substr = dict.text.slice(start, end);
1331
+
1332
+ if (substr.trim()) {
1333
+ this.wrapRangeAcross(dict, start, end, nodeOrArray => { // filter
1334
+ return filterCb(nodeOrArray, range, substr, index);
1335
+
1336
+ }, (elemOrRange, rangeStart) => { // each
1337
+ if (rangeStart) {
1338
+ count++;
1339
+ }
1340
+ eachCb(elemOrRange, range, {
1341
+ matchStart: rangeStart,
1342
+ count: count
1343
+ });
1344
+ });
1345
+ } else {
1346
+ // whitespace only; even if wrapped it is not visible
1347
+ logs.push({ text: 'Skipping whitespace only range: ', obj: range, level });
1348
+ skipped.push(range);
1349
+ }
1350
+ });
1351
+
1352
+ this.log(`Valid ranges: ${JSON.stringify(array.filter(range => !skipped.includes(range)))}`);
1353
+ endCb(count, logs);
1354
+ }, lines);
1355
+ }
1356
+
1357
+ /**
1358
+ * Unwraps the specified DOM node with its content (text nodes or HTML)
1359
+ * without destroying possibly present events (using innerHTML) and normalizes text nodes
1360
+ * @param {HTMLElement} node - The DOM node to unwrap
1361
+ * @access protected
1362
+ */
1363
+ unwrapMatches(node) {
1364
+ const parent = node.parentNode,
1365
+ first = node.firstChild;
1366
+
1367
+ if (node.childNodes.length === 1) {
1368
+ // unwraps and normalizes text nodes
1369
+ if (first.nodeType === 3) {
1370
+ // the most common case - mark element with child text node
1371
+ const previous = node.previousSibling,
1372
+ next = node.nextSibling;
1373
+
1374
+ if (previous && previous.nodeType === 3) {
1375
+ if (next && next.nodeType === 3) {
1376
+ previous.nodeValue += first.nodeValue + next.nodeValue;
1377
+ parent.removeChild(next);
1378
+
1379
+ } else {
1380
+ previous.nodeValue += first.nodeValue;
1381
+ }
1382
+
1383
+ } else if (next && next.nodeType === 3) {
1384
+ next.nodeValue = first.nodeValue + next.nodeValue;
1385
+
1386
+ } else {
1387
+ parent.replaceChild(node.firstChild, node);
1388
+ return;
1389
+ }
1390
+ parent.removeChild(node);
1391
+
1392
+ } else {
1393
+ // most likely is a nested mark element or modified by user element
1394
+ parent.replaceChild(node.firstChild, node);
1395
+ }
1396
+
1397
+ } else {
1398
+ if ( !first) {
1399
+ // an empty mark element
1400
+ parent.removeChild(node);
1401
+
1402
+ } else {
1403
+ // most likely is a nested mark element(s) with sibling text node(s) or modified by user element(s)
1404
+ let docFrag = this.opt.window.document.createDocumentFragment();
1405
+ while (node.firstChild) {
1406
+ docFrag.appendChild(node.removeChild(node.firstChild));
1407
+ }
1408
+ parent.replaceChild(docFrag, node);
1409
+ }
1410
+ parent.normalize();
1411
+ }
1412
+ }
1413
+
1414
+ /**
1415
+ * Callback to filter matches
1416
+ * @callback Mark~markRegExpFilterCallback
1417
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1418
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1419
+ * @param {string} match - The matching string:
1420
+ * 1) without 'ignoreGroups' and 'separateGroups' options - the whole match.
1421
+ * 2) with 'ignoreGroups' option - the match[ignoreGroups+1] group matching string.
1422
+ * 3) with 'separateGroups' option - the current group matching string
1423
+ * @param {number} matchesSoFar - The number of wrapped matches so far
1424
+ * @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
1425
+ */
1426
+ /**
1427
+ * Callback for each marked element
1428
+ * @callback Mark~markRegExpEachCallback
1429
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1430
+ * @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
1431
+ */
1432
+ /**
1433
+ * Callback if there were no matches
1434
+ * @callback Mark~markRegExpNoMatchCallback
1435
+ * @param {RegExp} regexp - The regular expression
1436
+ */
1437
+
1438
+ /**
1439
+ * These options also include the common options from {@link Mark~commonOptions}
1440
+ * @typedef Mark~markRegExpOptions
1441
+ * @type {object.<string>}
1442
+ * @property {number} [ignoreGroups=0] - A number of RegExp capturing groups to ignore from the beginning of a match
1443
+ * @property {boolean} [separateGroups] - Whether to mark RegExp capturing groups instead of whole match
1444
+ * @property {Mark~markRegExpNoMatchCallback} [noMatch]
1445
+ * @property {Mark~markRegExpFilterCallback} [filter]
1446
+ * @property {Mark~markRegExpEachCallback} [each]
1447
+ */
1448
+ /**
1449
+ * Marks a custom regular expression
1450
+ * @param {RegExp} regexp - The regular expression
1451
+ * @param {Mark~markRegExpOptions} [opt] - Optional options object
1452
+ * @access public
1453
+ */
1454
+ markRegExp(regexp, opt) {
1455
+ this.opt = opt;
1456
+
1457
+ let totalMarks = 0,
1458
+ matchesSoFar = 0,
1459
+ across = this.opt.acrossElements,
1460
+ fn = 'processMatches';
1461
+
1462
+ if (this.opt.separateGroups) {
1463
+ if ( !regexp.hasIndices) {
1464
+ throw new Error('Mark.js: RegExp must have a `d` flag');
1465
+ }
1466
+ fn = across ? 'processGroupsAcross' : 'processGroups';
1467
+
1468
+ } else if (across) {
1469
+ fn = 'processMatchesAcross';
1470
+ }
1471
+
1472
+ const info = { count: 0, abort: false };
1473
+
1474
+ // solves backward-compatibility
1475
+ if ( !regexp.global && !regexp.sticky) {
1476
+ let splits = regexp.toString().split('/');
1477
+ regexp = new RegExp(regexp.source, 'g' + splits[splits.length-1]);
1478
+ this.log('RegExp is recompiled - it must have a `g` flag', 'warn');
1479
+ }
1480
+ this.log(`RegExp "${regexp}"`);
1481
+
1482
+ this[fn](regexp, this.opt.ignoreGroups, info, (nodeOrArray, match, filterInfo) => { // filter
1483
+ return this.opt.filter(nodeOrArray, match, matchesSoFar, filterInfo);
1484
+
1485
+ }, (elemOrRange, eachInfo) => { // each
1486
+ matchesSoFar = eachInfo.count;
1487
+ totalMarks++;
1488
+ this.opt.each(elemOrRange, eachInfo);
1489
+
1490
+ }, (totalMatches) => { // done
1491
+ if (totalMatches === 0) {
1492
+ this.opt.noMatch(regexp);
1493
+ }
1494
+ this.registerHighlight();
1495
+ this.opt.done(totalMarks, totalMatches);
1496
+ });
1497
+ }
1498
+
1499
+ /**
1500
+ * Callback to filter matches
1501
+ * @callback Mark~markFilterCallback
1502
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1503
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1504
+ * @param {string} term - The current term
1505
+ * @param {number} matches - The number of all wrapped matches so far
1506
+ * @param {number} termMatches - The number of wrapped matches for the current term so far
1507
+ * @param {Mark~filterInfoObject} filterInfo - The object containing the match information.
1508
+ */
1509
+ /**
1510
+ * Callback for each marked element
1511
+ * @callback Mark~markEachCallback
1512
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1513
+ * @param {Mark~eachInfoObject} eachInfo - The object containing the match information.
1514
+ */
1515
+ /**
1516
+ * Callback if there were no matches
1517
+ * @callback Mark~markNoMatchCallback
1518
+ * @param {string[]} array - Not found search terms
1519
+ */
1520
+ /**
1521
+ * Callback when finished
1522
+ * @callback Mark~commonDoneCallback
1523
+ * @param {number} totalMatches - The total number of matches
1524
+ * @param {object} termStats - The object containing an individual term's matches counts for {@link Mark#mark} method
1525
+ */
1526
+
1527
+ /**
1528
+ * These options also include the common options from {@link Mark~commonOptions}
1529
+ * @typedef Mark~markOptions
1530
+ * @type {object.<string>}
1531
+ * @property {boolean} [separateWordSearch=true] - Whether to break term into words
1532
+ * and search for individual word instead of the complete term
1533
+ * @property {Mark~markFilterCallback} [filter]
1534
+ */
1535
+ /**
1536
+ * Marks the specified search terms
1537
+ * @param {string|string[]} [sv] - A search string or an array of search strings
1538
+ * @param {Mark~markOptions} [opt] - Optional options object
1539
+ * @access public
1540
+ */
1541
+ mark(sv, opt) {
1542
+ this.opt = opt;
1543
+ const { terms, termStats } = this.getSeachTerms(sv);
1544
+
1545
+ if ( !terms.length) {
1546
+ this.opt.done(0, 0, termStats);
1547
+ return;
1548
+ }
1549
+
1550
+ let index = 0,
1551
+ totalMarks = 0,
1552
+ matchesSoFar = 0,
1553
+ term;
1554
+
1555
+ const across = this.opt.acrossElements,
1556
+ fn = across ? 'processMatchesAcross' : 'processMatches',
1557
+ array = this.getRegExps(terms),
1558
+ info = { count: 0, abort: false };
1559
+
1560
+ const loop = ({ regex, regTerms }) => {
1561
+ this.log(`RegExp ${regex}`);
1562
+
1563
+ this[fn](regex, 1, info, (nodeOrArray, _, filterInfo) => { // filter
1564
+ if ( !across || filterInfo.matchStart) {
1565
+ term = this.getCurrentTerm(filterInfo.match, regTerms);
1566
+ }
1567
+ // termStats[term] is the number of wrapped matches so far for the current term
1568
+ return this.opt.filter(nodeOrArray, term, matchesSoFar, termStats[term], filterInfo);
1569
+
1570
+ }, (elemOrRange, eachInfo) => { // each
1571
+ totalMarks++;
1572
+ matchesSoFar = eachInfo.count;
1573
+
1574
+ if ( !across || eachInfo.matchStart) {
1575
+ termStats[term] += 1;
1576
+ }
1577
+ this.opt.each(elemOrRange, eachInfo);
1578
+
1579
+ }, (totalMatches) => { // end
1580
+ const noMatches = regTerms.filter(term => termStats[term] === 0);
1581
+ if (noMatches.length) {
1582
+ this.opt.noMatch(noMatches);
1583
+ }
1584
+
1585
+ if ( !info.abort && ++index < array.length) {
1586
+ loop(array[index]);
1587
+ } else {
1588
+ this.registerHighlight();
1589
+ this.opt.done(totalMarks, totalMatches, termStats);
1590
+ }
1591
+ });
1592
+ };
1593
+
1594
+ loop(array[0]);
1595
+ }
1596
+
1597
+ /**
1598
+ * @param {array} match - The result of RegExp exec() method
1599
+ * @param {array} terms - The array of strings
1600
+ * @return {string} - The matched term
1601
+ */
1602
+ getCurrentTerm(match, terms) {
1603
+ // it's better to search from the end of array because the terms are sorted by
1604
+ // length in descending order - shorter term appears more frequently
1605
+ let i = match.length;
1606
+ while (--i > 2) {
1607
+ // the current term index is the first non-undefined capturing group index minus 3
1608
+ if (match[i]) {
1609
+ // the first 3 groups are: match[0], lookbehind, and main group
1610
+ return terms[i-3];
1611
+ }
1612
+ }
1613
+ return ' ';
1614
+ }
1615
+
1616
+ /**
1617
+ * Splits an array of strings into chunks by the specified number and creates RegExp from each chunk
1618
+ * @param {array} terms - The array of strings
1619
+ * @return {array} - The array of arrays with RegExp and its term chunks
1620
+ */
1621
+ getRegExps(terms) {
1622
+ const creator = new RegExpCreator(this.opt),
1623
+ option = this.opt.combineBy || this.opt.combinePatterns,
1624
+ length = terms.length,
1625
+ array = [];
1626
+ let num = 100,
1627
+ value;
1628
+
1629
+ if (option === Infinity) {
1630
+ num = length;
1631
+ } else if ( !isNaN(+option) && (value = parseInt(option)) > 0) {
1632
+ num = value;
1633
+ }
1634
+
1635
+ for (let i = 0; i < length; i += num) {
1636
+ // get a chunk of terms to create combine pattern
1637
+ const chunk = terms.slice(i, Math.min(i + num, length));
1638
+ array.push({ regex: creator.create(chunk), regTerms: chunk });
1639
+ }
1640
+ return array;
1641
+ }
1642
+
1643
+ /**
1644
+ * @typedef Mark~rangeObject
1645
+ * @type {object}
1646
+ * @property {number} start - The start index within the composite string
1647
+ * @property {number} length - The length of the string to mark within the composite string.
1648
+ */
1649
+ /**
1650
+ * @typedef Mark~setOfRanges
1651
+ * @type {object[]}
1652
+ * @property {Mark~rangeObject}
1653
+ */
1654
+
1655
+ /**
1656
+ * @typedef Mark~rangeInfoObject
1657
+ * @type {object}
1658
+ * @property {boolean} matchStart - Indicate the start of range
1659
+ * @property {number} count - The current number of wrapped ranges
1660
+ */
1661
+ /**
1662
+ * These options also include the common options from {@link Mark~commonOptions}
1663
+ * @typedef Mark~markRangesOptions
1664
+ * @type {object.<string>}
1665
+ * @property {Mark~markRangesEachCallback} [each]
1666
+ * @property {Mark~markRangesNoMatchCallback} [noMatch]
1667
+ * @property {Mark~markRangesFilterCallback} [filter]
1668
+ */
1669
+
1670
+ /**
1671
+ * Callback to filter matches
1672
+ * @callback Mark~markRangesFilterCallback
1673
+ * @param {Text|Text[]} nodeOrArray - The current text node or an array of text nodes when using the Highlight API
1674
+ * with the options 'acrossElements: true' and 'rangeAcrossElements: true'
1675
+ * @param {Mark~rangeObject} range - The range object
1676
+ * @param {string} match - The current range matching string
1677
+ * @param {number} index - The current range index ???
1678
+ */
1679
+ /**
1680
+ * Callback for each marked element
1681
+ * @callback Mark~markRangesEachCallback
1682
+ * @param {HTMLElement|StaticRange|Range} elemOrRange - The marked DOM element or range (Highlight API)
1683
+ * @param {Mark~rangeObject} range - The range object
1684
+ * @param {Mark~rangeInfoObject} - The object containing the range information
1685
+ */
1686
+ /**
1687
+ * Callback if a processed range is invalid, out-of-bounds, overlaps another
1688
+ * range, or only matches whitespace
1689
+ * @callback Mark~markRangesNoMatchCallback
1690
+ * @param {Mark~rangeObject} range - The range object
1691
+ */
1692
+
1693
+ /**
1694
+ * Marks an array of objects containing start and length properties
1695
+ * @param {Mark~setOfRanges} ranges - The original array of objects
1696
+ * @param {Mark~markRangesOptions} [opt] - Optional options object
1697
+ * @access public
1698
+ */
1699
+ markRanges(ranges, opt) {
1700
+ this.opt = opt;
1701
+
1702
+ if (Array.isArray(ranges)) {
1703
+ let totalMarks = 0;
1704
+
1705
+ this.processRanges(ranges, (nodeOrArray, range, match, index) => { // filter
1706
+ return this.opt.filter(nodeOrArray, range, match, index);
1707
+
1708
+ }, (elemOrRange, range, rangeInfo) => { // each
1709
+ totalMarks++;
1710
+ this.opt.each(elemOrRange, range, rangeInfo);
1711
+
1712
+ }, (totalRanges, logs) => { // end
1713
+ this.report(logs);
1714
+ this.registerHighlight();
1715
+ this.opt.done(totalMarks, totalRanges);
1716
+ });
1717
+
1718
+ } else {
1719
+ this.report([{ text: 'markRanges() accept an array of objects: ', obj: ranges, level: 'error' }]);
1720
+ this.opt.done(0, 0);
1721
+ }
1722
+ }
1723
+
1724
+ /**
1725
+ * Iterates over specified names or the default name, if the HighlightRegistry contains name,
1726
+ * it deletes all (if the 'exclude' option is specified according this option) ranges from the Highlight object;
1727
+ * next, if allowed, unwraps all marked elements inside the context and normalizes text nodes
1728
+ * @param {Mark~commonOptions} [opt] - Optional options object without each,
1729
+ * noMatch and acrossElements properties
1730
+ * @access public
1731
+ */
1732
+ unmark(opt) {
1733
+ this.opt = opt;
1734
+ const registry = CSS.highlights,
1735
+ exclude = this.opt.exclude && this.opt.exclude.length;
1736
+ // if the browser supports the Highlight API
1737
+ if (registry) {
1738
+ let names = this.opt.highlightName,
1739
+ highlight;
1740
+ if (typeof names === 'string') names = [names];
1741
+
1742
+ names.forEach((name) => {
1743
+ if ((highlight = registry.get(name)) && highlight.size) {
1744
+ // unregister the Highlight object before deleting ranges
1745
+ registry.delete(name);
1746
+ // iterates over highlight when 'exclude' option is specified
1747
+ if (exclude) {
1748
+ highlight.forEach((range) => {
1749
+ let node = range.startContainer;
1750
+
1751
+ if (node.nodeType === 3) node = node.parentNode;
1752
+
1753
+ if ( !this.excluded(node)) highlight.delete(range);
1754
+ });
1755
+
1756
+ } else {
1757
+ // much faster way to remove highlights
1758
+ highlight.clear();
1759
+ }
1760
+ // register the Highlight object with excluded ranges
1761
+ if (highlight.size) registry.set(name, highlight);
1762
+ }
1763
+ });
1764
+ }
1765
+ // removes only StaticRange/Range objects
1766
+ if (this.opt.highlight) {
1767
+ this.opt.done();
1768
+ return;
1769
+ }
1770
+
1771
+ let selector = this.opt.element + '[data-markjs]';
1772
+
1773
+ if (this.opt.className) {
1774
+ selector += `.${this.opt.className}`;
1775
+ }
1776
+ this.log(`Removal selector "${selector}"`);
1777
+
1778
+ this.iterator.forEachNode(this.filter.SHOW_ELEMENT, node => { // each
1779
+ this.unwrapMatches(node);
1780
+ }, node => { // filter
1781
+ return DOMIterator.matches(node, selector) && !(exclude && this.excluded(node));
1782
+ }, this.opt.done);
1783
+ }
1784
+
1785
+ /**
1786
+ * Registers a Highlight object using the HighlightRegistry
1787
+ */
1788
+ registerHighlight() {
1789
+ const highlight = this.opt.highlight;
1790
+
1791
+ if (highlight) {
1792
+ const name = this.opt.highlightName,
1793
+ // eslint-disable-next-line
1794
+ registry = CSS.highlights;
1795
+
1796
+ if (this.rangeArray.length) {
1797
+ registry.delete(name);
1798
+
1799
+ if (highlight.size) {
1800
+ highlight.forEach(range => {
1801
+ this.rangeArray.push(range);
1802
+ });
1803
+ highlight.clear();
1804
+ }
1805
+
1806
+ this.rangeArray.sort((a, b) => a.absoluteOffset - b.absoluteOffset);
1807
+ this.rangeArray.forEach(range => {
1808
+ highlight.add(range);
1809
+ });
1810
+ this.rangeArray = [];
1811
+ }
1812
+ if (highlight.size) registry.set(name, highlight);
1813
+ }
1814
+ }
1815
+ }
1816
+
1817
+ export default Mark;