1/*!
2 * jquery.fancytree.filter.js
3 *
4 * Remove or highlight tree nodes, based on a filter.
5 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
6 *
7 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de)
8 *
9 * Released under the MIT license
10 * https://github.com/mar10/fancytree/wiki/LicenseInfo
11 *
12 * @version 2.38.3
13 * @date 2023-02-01T20:52:50Z
14 */
15
16(function (factory) {
17	if (typeof define === "function" && define.amd) {
18		// AMD. Register as an anonymous module.
19		define(["jquery", "./jquery.fancytree"], factory);
20	} else if (typeof module === "object" && module.exports) {
21		// Node/CommonJS
22		require("./jquery.fancytree");
23		module.exports = factory(require("jquery"));
24	} else {
25		// Browser globals
26		factory(jQuery);
27	}
28})(function ($) {
29	"use strict";
30
31	/*******************************************************************************
32	 * Private functions and variables
33	 */
34
35	var KeyNoData = "__not_found__",
36		escapeHtml = $.ui.fancytree.escapeHtml,
37		exoticStartChar = "\uFFF7",
38		exoticEndChar = "\uFFF8";
39	function _escapeRegex(str) {
40		return (str + "").replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
41	}
42
43	function extractHtmlText(s) {
44		if (s.indexOf(">") >= 0) {
45			return $("<div/>").html(s).text();
46		}
47		return s;
48	}
49
50	/**
51	 * @description Marks the matching charecters of `text` either by `mark` or
52	 * by exotic*Chars (if `escapeTitles` is `true`) based on `regexMatchArray`
53	 * which is an array of matching groups.
54	 * @param {string} text
55	 * @param {RegExpMatchArray} regexMatchArray
56	 */
57	function _markFuzzyMatchedChars(text, regexMatchArray, escapeTitles) {
58		// It is extremely infuriating that we can not use `let` or `const` or arrow functions.
59		// Damn you IE!!!
60		var matchingIndices = [];
61		// get the indices of matched characters (Iterate through `RegExpMatchArray`)
62		for (
63			var _matchingArrIdx = 1;
64			_matchingArrIdx < regexMatchArray.length;
65			_matchingArrIdx++
66		) {
67			var _mIdx =
68				// get matching char index by cumulatively adding
69				// the matched group length
70				regexMatchArray[_matchingArrIdx].length +
71				(_matchingArrIdx === 1 ? 0 : 1) +
72				(matchingIndices[matchingIndices.length - 1] || 0);
73			matchingIndices.push(_mIdx);
74		}
75		// Map each `text` char to its position and store in `textPoses`.
76		var textPoses = text.split("");
77		if (escapeTitles) {
78			// If escaping the title, then wrap the matchng char within exotic chars
79			matchingIndices.forEach(function (v) {
80				textPoses[v] = exoticStartChar + textPoses[v] + exoticEndChar;
81			});
82		} else {
83			// Otherwise, Wrap the matching chars within `mark`.
84			matchingIndices.forEach(function (v) {
85				textPoses[v] = "<mark>" + textPoses[v] + "</mark>";
86			});
87		}
88		// Join back the modified `textPoses` to create final highlight markup.
89		return textPoses.join("");
90	}
91	$.ui.fancytree._FancytreeClass.prototype._applyFilterImpl = function (
92		filter,
93		branchMode,
94		_opts
95	) {
96		var match,
97			statusNode,
98			re,
99			reHighlight,
100			reExoticStartChar,
101			reExoticEndChar,
102			temp,
103			prevEnableUpdate,
104			count = 0,
105			treeOpts = this.options,
106			escapeTitles = treeOpts.escapeTitles,
107			prevAutoCollapse = treeOpts.autoCollapse,
108			opts = $.extend({}, treeOpts.filter, _opts),
109			hideMode = opts.mode === "hide",
110			leavesOnly = !!opts.leavesOnly && !branchMode;
111
112		// Default to 'match title substring (not case sensitive)'
113		if (typeof filter === "string") {
114			if (filter === "") {
115				this.warn(
116					"Fancytree passing an empty string as a filter is handled as clearFilter()."
117				);
118				this.clearFilter();
119				return;
120			}
121			if (opts.fuzzy) {
122				// See https://codereview.stackexchange.com/questions/23899/faster-javascript-fuzzy-string-matching-function/23905#23905
123				// and http://www.quora.com/How-is-the-fuzzy-search-algorithm-in-Sublime-Text-designed
124				// and http://www.dustindiaz.com/autocomplete-fuzzy-matching
125				match = filter
126					.split("")
127					// Escaping the `filter` will not work because,
128					// it gets further split into individual characters. So,
129					// escape each character after splitting
130					.map(_escapeRegex)
131					.reduce(function (a, b) {
132						// create capture groups for parts that comes before
133						// the character
134						return a + "([^" + b + "]*)" + b;
135					}, "");
136			} else {
137				match = _escapeRegex(filter); // make sure a '.' is treated literally
138			}
139			re = new RegExp(match, "i");
140			reHighlight = new RegExp(_escapeRegex(filter), "gi");
141			if (escapeTitles) {
142				reExoticStartChar = new RegExp(
143					_escapeRegex(exoticStartChar),
144					"g"
145				);
146				reExoticEndChar = new RegExp(_escapeRegex(exoticEndChar), "g");
147			}
148			filter = function (node) {
149				if (!node.title) {
150					return false;
151				}
152				var text = escapeTitles
153						? node.title
154						: extractHtmlText(node.title),
155					// `.match` instead of `.test` to get the capture groups
156					res = text.match(re);
157				if (res && opts.highlight) {
158					if (escapeTitles) {
159						if (opts.fuzzy) {
160							temp = _markFuzzyMatchedChars(
161								text,
162								res,
163								escapeTitles
164							);
165						} else {
166							// #740: we must not apply the marks to escaped entity names, e.g. `&quot;`
167							// Use some exotic characters to mark matches:
168							temp = text.replace(reHighlight, function (s) {
169								return exoticStartChar + s + exoticEndChar;
170							});
171						}
172						// now we can escape the title...
173						node.titleWithHighlight = escapeHtml(temp)
174							// ... and finally insert the desired `<mark>` tags
175							.replace(reExoticStartChar, "<mark>")
176							.replace(reExoticEndChar, "</mark>");
177					} else {
178						if (opts.fuzzy) {
179							node.titleWithHighlight = _markFuzzyMatchedChars(
180								text,
181								res
182							);
183						} else {
184							node.titleWithHighlight = text.replace(
185								reHighlight,
186								function (s) {
187									return "<mark>" + s + "</mark>";
188								}
189							);
190						}
191					}
192					// node.debug("filter", escapeTitles, text, node.titleWithHighlight);
193				}
194				return !!res;
195			};
196		}
197
198		this.enableFilter = true;
199		this.lastFilterArgs = arguments;
200
201		prevEnableUpdate = this.enableUpdate(false);
202
203		this.$div.addClass("fancytree-ext-filter");
204		if (hideMode) {
205			this.$div.addClass("fancytree-ext-filter-hide");
206		} else {
207			this.$div.addClass("fancytree-ext-filter-dimm");
208		}
209		this.$div.toggleClass(
210			"fancytree-ext-filter-hide-expanders",
211			!!opts.hideExpanders
212		);
213		// Reset current filter
214		this.rootNode.subMatchCount = 0;
215		this.visit(function (node) {
216			delete node.match;
217			delete node.titleWithHighlight;
218			node.subMatchCount = 0;
219		});
220		statusNode = this.getRootNode()._findDirectChild(KeyNoData);
221		if (statusNode) {
222			statusNode.remove();
223		}
224
225		// Adjust node.hide, .match, and .subMatchCount properties
226		treeOpts.autoCollapse = false; // #528
227
228		this.visit(function (node) {
229			if (leavesOnly && node.children != null) {
230				return;
231			}
232			var res = filter(node),
233				matchedByBranch = false;
234
235			if (res === "skip") {
236				node.visit(function (c) {
237					c.match = false;
238				}, true);
239				return "skip";
240			}
241			if (!res && (branchMode || res === "branch") && node.parent.match) {
242				res = true;
243				matchedByBranch = true;
244			}
245			if (res) {
246				count++;
247				node.match = true;
248				node.visitParents(function (p) {
249					if (p !== node) {
250						p.subMatchCount += 1;
251					}
252					// Expand match (unless this is no real match, but only a node in a matched branch)
253					if (opts.autoExpand && !matchedByBranch && !p.expanded) {
254						p.setExpanded(true, {
255							noAnimation: true,
256							noEvents: true,
257							scrollIntoView: false,
258						});
259						p._filterAutoExpanded = true;
260					}
261				}, true);
262			}
263		});
264		treeOpts.autoCollapse = prevAutoCollapse;
265
266		if (count === 0 && opts.nodata && hideMode) {
267			statusNode = opts.nodata;
268			if (typeof statusNode === "function") {
269				statusNode = statusNode();
270			}
271			if (statusNode === true) {
272				statusNode = {};
273			} else if (typeof statusNode === "string") {
274				statusNode = { title: statusNode };
275			}
276			statusNode = $.extend(
277				{
278					statusNodeType: "nodata",
279					key: KeyNoData,
280					title: this.options.strings.noData,
281				},
282				statusNode
283			);
284
285			this.getRootNode().addNode(statusNode).match = true;
286		}
287		// Redraw whole tree
288		this._callHook("treeStructureChanged", this, "applyFilter");
289		// this.render();
290		this.enableUpdate(prevEnableUpdate);
291		return count;
292	};
293
294	/**
295	 * [ext-filter] Dimm or hide nodes.
296	 *
297	 * @param {function | string} filter
298	 * @param {boolean} [opts={autoExpand: false, leavesOnly: false}]
299	 * @returns {integer} count
300	 * @alias Fancytree#filterNodes
301	 * @requires jquery.fancytree.filter.js
302	 */
303	$.ui.fancytree._FancytreeClass.prototype.filterNodes = function (
304		filter,
305		opts
306	) {
307		if (typeof opts === "boolean") {
308			opts = { leavesOnly: opts };
309			this.warn(
310				"Fancytree.filterNodes() leavesOnly option is deprecated since 2.9.0 / 2015-04-19. Use opts.leavesOnly instead."
311			);
312		}
313		return this._applyFilterImpl(filter, false, opts);
314	};
315
316	/**
317	 * [ext-filter] Dimm or hide whole branches.
318	 *
319	 * @param {function | string} filter
320	 * @param {boolean} [opts={autoExpand: false}]
321	 * @returns {integer} count
322	 * @alias Fancytree#filterBranches
323	 * @requires jquery.fancytree.filter.js
324	 */
325	$.ui.fancytree._FancytreeClass.prototype.filterBranches = function (
326		filter,
327		opts
328	) {
329		return this._applyFilterImpl(filter, true, opts);
330	};
331
332	/**
333	 * [ext-filter] Re-apply current filter.
334	 *
335	 * @returns {integer} count
336	 * @alias Fancytree#updateFilter
337	 * @requires jquery.fancytree.filter.js
338	 * @since 2.38
339	 */
340	$.ui.fancytree._FancytreeClass.prototype.updateFilter = function () {
341		if (
342			this.enableFilter &&
343			this.lastFilterArgs &&
344			this.options.filter.autoApply
345		) {
346			this._applyFilterImpl.apply(this, this.lastFilterArgs);
347		} else {
348			this.warn("updateFilter(): no filter active.");
349		}
350	};
351
352	/**
353	 * [ext-filter] Reset the filter.
354	 *
355	 * @alias Fancytree#clearFilter
356	 * @requires jquery.fancytree.filter.js
357	 */
358	$.ui.fancytree._FancytreeClass.prototype.clearFilter = function () {
359		var $title,
360			statusNode = this.getRootNode()._findDirectChild(KeyNoData),
361			escapeTitles = this.options.escapeTitles,
362			enhanceTitle = this.options.enhanceTitle,
363			prevEnableUpdate = this.enableUpdate(false);
364
365		if (statusNode) {
366			statusNode.remove();
367		}
368		// we also counted root node's subMatchCount
369		delete this.rootNode.match;
370		delete this.rootNode.subMatchCount;
371
372		this.visit(function (node) {
373			if (node.match && node.span) {
374				// #491, #601
375				$title = $(node.span).find(">span.fancytree-title");
376				if (escapeTitles) {
377					$title.text(node.title);
378				} else {
379					$title.html(node.title);
380				}
381				if (enhanceTitle) {
382					enhanceTitle(
383						{ type: "enhanceTitle" },
384						{ node: node, $title: $title }
385					);
386				}
387			}
388			delete node.match;
389			delete node.subMatchCount;
390			delete node.titleWithHighlight;
391			if (node.$subMatchBadge) {
392				node.$subMatchBadge.remove();
393				delete node.$subMatchBadge;
394			}
395			if (node._filterAutoExpanded && node.expanded) {
396				node.setExpanded(false, {
397					noAnimation: true,
398					noEvents: true,
399					scrollIntoView: false,
400				});
401			}
402			delete node._filterAutoExpanded;
403		});
404		this.enableFilter = false;
405		this.lastFilterArgs = null;
406		this.$div.removeClass(
407			"fancytree-ext-filter fancytree-ext-filter-dimm fancytree-ext-filter-hide"
408		);
409		this._callHook("treeStructureChanged", this, "clearFilter");
410		// this.render();
411		this.enableUpdate(prevEnableUpdate);
412	};
413
414	/**
415	 * [ext-filter] Return true if a filter is currently applied.
416	 *
417	 * @returns {Boolean}
418	 * @alias Fancytree#isFilterActive
419	 * @requires jquery.fancytree.filter.js
420	 * @since 2.13
421	 */
422	$.ui.fancytree._FancytreeClass.prototype.isFilterActive = function () {
423		return !!this.enableFilter;
424	};
425
426	/**
427	 * [ext-filter] Return true if this node is matched by current filter (or no filter is active).
428	 *
429	 * @returns {Boolean}
430	 * @alias FancytreeNode#isMatched
431	 * @requires jquery.fancytree.filter.js
432	 * @since 2.13
433	 */
434	$.ui.fancytree._FancytreeNodeClass.prototype.isMatched = function () {
435		return !(this.tree.enableFilter && !this.match);
436	};
437
438	/*******************************************************************************
439	 * Extension code
440	 */
441	$.ui.fancytree.registerExtension({
442		name: "filter",
443		version: "2.38.3",
444		// Default options for this extension.
445		options: {
446			autoApply: true, // Re-apply last filter if lazy data is loaded
447			autoExpand: false, // Expand all branches that contain matches while filtered
448			counter: true, // Show a badge with number of matching child nodes near parent icons
449			fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar'
450			hideExpandedCounter: true, // Hide counter badge if parent is expanded
451			hideExpanders: false, // Hide expanders if all child nodes are hidden by filter
452			highlight: true, // Highlight matches by wrapping inside <mark> tags
453			leavesOnly: false, // Match end nodes only
454			nodata: true, // Display a 'no data' status node if result is empty
455			mode: "dimm", // Grayout unmatched nodes (pass "hide" to remove unmatched node instead)
456		},
457		nodeLoadChildren: function (ctx, source) {
458			var tree = ctx.tree;
459
460			return this._superApply(arguments).done(function () {
461				if (
462					tree.enableFilter &&
463					tree.lastFilterArgs &&
464					ctx.options.filter.autoApply
465				) {
466					tree._applyFilterImpl.apply(tree, tree.lastFilterArgs);
467				}
468			});
469		},
470		nodeSetExpanded: function (ctx, flag, callOpts) {
471			var node = ctx.node;
472
473			delete node._filterAutoExpanded;
474			// Make sure counter badge is displayed again, when node is beeing collapsed
475			if (
476				!flag &&
477				ctx.options.filter.hideExpandedCounter &&
478				node.$subMatchBadge
479			) {
480				node.$subMatchBadge.show();
481			}
482			return this._superApply(arguments);
483		},
484		nodeRenderStatus: function (ctx) {
485			// Set classes for current status
486			var res,
487				node = ctx.node,
488				tree = ctx.tree,
489				opts = ctx.options.filter,
490				$title = $(node.span).find("span.fancytree-title"),
491				$span = $(node[tree.statusClassPropName]),
492				enhanceTitle = ctx.options.enhanceTitle,
493				escapeTitles = ctx.options.escapeTitles;
494
495			res = this._super(ctx);
496			// nothing to do, if node was not yet rendered
497			if (!$span.length || !tree.enableFilter) {
498				return res;
499			}
500			$span
501				.toggleClass("fancytree-match", !!node.match)
502				.toggleClass("fancytree-submatch", !!node.subMatchCount)
503				.toggleClass(
504					"fancytree-hide",
505					!(node.match || node.subMatchCount)
506				);
507			// Add/update counter badge
508			if (
509				opts.counter &&
510				node.subMatchCount &&
511				(!node.isExpanded() || !opts.hideExpandedCounter)
512			) {
513				if (!node.$subMatchBadge) {
514					node.$subMatchBadge = $(
515						"<span class='fancytree-childcounter'/>"
516					);
517					$(
518						"span.fancytree-icon, span.fancytree-custom-icon",
519						node.span
520					).append(node.$subMatchBadge);
521				}
522				node.$subMatchBadge.show().text(node.subMatchCount);
523			} else if (node.$subMatchBadge) {
524				node.$subMatchBadge.hide();
525			}
526			// node.debug("nodeRenderStatus", node.titleWithHighlight, node.title)
527			// #601: also check for $title.length, because we don't need to render
528			// if node.span is null (i.e. not rendered)
529			if (node.span && (!node.isEditing || !node.isEditing.call(node))) {
530				if (node.titleWithHighlight) {
531					$title.html(node.titleWithHighlight);
532				} else if (escapeTitles) {
533					$title.text(node.title);
534				} else {
535					$title.html(node.title);
536				}
537				if (enhanceTitle) {
538					enhanceTitle(
539						{ type: "enhanceTitle" },
540						{ node: node, $title: $title }
541					);
542				}
543			}
544			return res;
545		},
546	});
547	// Value returned by `require('jquery.fancytree..')`
548	return $.ui.fancytree;
549}); // End of closure
550