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. `"` 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