mar10/fancytree

View on GitHub
src/jquery.fancytree.filter.js

Summary

Maintainability
D
2 days
Test Coverage
/*!
 * jquery.fancytree.filter.js
 *
 * Remove or highlight tree nodes, based on a filter.
 * (Extension module for jquery.fancytree.js: https://github.com/mar10/fancytree/)
 *
 * Copyright (c) 2008-2023, Martin Wendt (https://wwWendt.de)
 *
 * Released under the MIT license
 * https://github.com/mar10/fancytree/wiki/LicenseInfo
 *
 * @version @VERSION
 * @date @DATE
 */

(function (factory) {
    if (typeof define === "function" && define.amd) {
        // AMD. Register as an anonymous module.
        define(["jquery", "./jquery.fancytree"], factory);
    } else if (typeof module === "object" && module.exports) {
        // Node/CommonJS
        require("./jquery.fancytree");
        module.exports = factory(require("jquery"));
    } else {
        // Browser globals
        factory(jQuery);
    }
})(function ($) {
    "use strict";

    /*******************************************************************************
     * Private functions and variables
     */

    var KeyNoData = "__not_found__",
        escapeHtml = $.ui.fancytree.escapeHtml,
        exoticStartChar = "\uFFF7",
        exoticEndChar = "\uFFF8";
    function _escapeRegex(str) {
        return (str + "").replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
    }

    function extractHtmlText(s) {
        if (s.indexOf(">") >= 0) {
            return $("<div/>").html(s).text();
        }
        return s;
    }

    /**
     * @description Marks the matching charecters of `text` either by `mark` or
     * by exotic*Chars (if `escapeTitles` is `true`) based on `regexMatchArray`
     * which is an array of matching groups.
     * @param {string} text
     * @param {RegExpMatchArray} regexMatchArray
     */
    function _markFuzzyMatchedChars(text, regexMatchArray, escapeTitles) {
        // It is extremely infuriating that we can not use `let` or `const` or arrow functions.
        // Damn you IE!!!
        var matchingIndices = [];
        // get the indices of matched characters (Iterate through `RegExpMatchArray`)
        for (
            var _matchingArrIdx = 1;
            _matchingArrIdx < regexMatchArray.length;
            _matchingArrIdx++
        ) {
            var _mIdx =
                // get matching char index by cumulatively adding
                // the matched group length
                regexMatchArray[_matchingArrIdx].length +
                (_matchingArrIdx === 1 ? 0 : 1) +
                (matchingIndices[matchingIndices.length - 1] || 0);
            matchingIndices.push(_mIdx);
        }
        // Map each `text` char to its position and store in `textPoses`.
        var textPoses = text.split("");
        if (escapeTitles) {
            // If escaping the title, then wrap the matchng char within exotic chars
            matchingIndices.forEach(function (v) {
                textPoses[v] = exoticStartChar + textPoses[v] + exoticEndChar;
            });
        } else {
            // Otherwise, Wrap the matching chars within `mark`.
            matchingIndices.forEach(function (v) {
                textPoses[v] = "<mark>" + textPoses[v] + "</mark>";
            });
        }
        // Join back the modified `textPoses` to create final highlight markup.
        return textPoses.join("");
    }
    $.ui.fancytree._FancytreeClass.prototype._applyFilterImpl = function (
        filter,
        branchMode,
        _opts
    ) {
        var match,
            statusNode,
            re,
            reHighlight,
            reExoticStartChar,
            reExoticEndChar,
            temp,
            prevEnableUpdate,
            count = 0,
            treeOpts = this.options,
            escapeTitles = treeOpts.escapeTitles,
            prevAutoCollapse = treeOpts.autoCollapse,
            opts = $.extend({}, treeOpts.filter, _opts),
            hideMode = opts.mode === "hide",
            leavesOnly = !!opts.leavesOnly && !branchMode;

        // Default to 'match title substring (not case sensitive)'
        if (typeof filter === "string") {
            if (filter === "") {
                this.warn(
                    "Fancytree passing an empty string as a filter is handled as clearFilter()."
                );
                this.clearFilter();
                return;
            }
            if (opts.fuzzy) {
                // See https://codereview.stackexchange.com/questions/23899/faster-javascript-fuzzy-string-matching-function/23905#23905
                // and http://www.quora.com/How-is-the-fuzzy-search-algorithm-in-Sublime-Text-designed
                // and http://www.dustindiaz.com/autocomplete-fuzzy-matching
                match = filter
                    .split("")
                    // Escaping the `filter` will not work because,
                    // it gets further split into individual characters. So,
                    // escape each character after splitting
                    .map(_escapeRegex)
                    .reduce(function (a, b) {
                        // create capture groups for parts that comes before
                        // the character
                        return a + "([^" + b + "]*)" + b;
                    }, "");
            } else {
                match = _escapeRegex(filter); // make sure a '.' is treated literally
            }
            re = new RegExp(match, "i");
            reHighlight = new RegExp(_escapeRegex(filter), "gi");
            if (escapeTitles) {
                reExoticStartChar = new RegExp(
                    _escapeRegex(exoticStartChar),
                    "g"
                );
                reExoticEndChar = new RegExp(_escapeRegex(exoticEndChar), "g");
            }
            filter = function (node) {
                if (!node.title) {
                    return false;
                }
                var text = escapeTitles
                        ? node.title
                        : extractHtmlText(node.title),
                    // `.match` instead of `.test` to get the capture groups
                    res = text.match(re);
                if (res && opts.highlight) {
                    if (escapeTitles) {
                        if (opts.fuzzy) {
                            temp = _markFuzzyMatchedChars(
                                text,
                                res,
                                escapeTitles
                            );
                        } else {
                            // #740: we must not apply the marks to escaped entity names, e.g. `&quot;`
                            // Use some exotic characters to mark matches:
                            temp = text.replace(reHighlight, function (s) {
                                return exoticStartChar + s + exoticEndChar;
                            });
                        }
                        // now we can escape the title...
                        node.titleWithHighlight = escapeHtml(temp)
                            // ... and finally insert the desired `<mark>` tags
                            .replace(reExoticStartChar, "<mark>")
                            .replace(reExoticEndChar, "</mark>");
                    } else {
                        if (opts.fuzzy) {
                            node.titleWithHighlight = _markFuzzyMatchedChars(
                                text,
                                res
                            );
                        } else {
                            node.titleWithHighlight = text.replace(
                                reHighlight,
                                function (s) {
                                    return "<mark>" + s + "</mark>";
                                }
                            );
                        }
                    }
                    // node.debug("filter", escapeTitles, text, node.titleWithHighlight);
                }
                return !!res;
            };
        }

        this.enableFilter = true;
        this.lastFilterArgs = arguments;

        prevEnableUpdate = this.enableUpdate(false);

        this.$div.addClass("fancytree-ext-filter");
        if (hideMode) {
            this.$div.addClass("fancytree-ext-filter-hide");
        } else {
            this.$div.addClass("fancytree-ext-filter-dimm");
        }
        this.$div.toggleClass(
            "fancytree-ext-filter-hide-expanders",
            !!opts.hideExpanders
        );
        // Reset current filter
        this.rootNode.subMatchCount = 0;
        this.visit(function (node) {
            delete node.match;
            delete node.titleWithHighlight;
            node.subMatchCount = 0;
        });
        statusNode = this.getRootNode()._findDirectChild(KeyNoData);
        if (statusNode) {
            statusNode.remove();
        }

        // Adjust node.hide, .match, and .subMatchCount properties
        treeOpts.autoCollapse = false; // #528

        this.visit(function (node) {
            if (leavesOnly && node.children != null) {
                return;
            }
            var res = filter(node),
                matchedByBranch = false;

            if (res === "skip") {
                node.visit(function (c) {
                    c.match = false;
                }, true);
                return "skip";
            }
            if (!res && (branchMode || res === "branch") && node.parent.match) {
                res = true;
                matchedByBranch = true;
            }
            if (res) {
                count++;
                node.match = true;
                node.visitParents(function (p) {
                    if (p !== node) {
                        p.subMatchCount += 1;
                    }
                    // Expand match (unless this is no real match, but only a node in a matched branch)
                    if (opts.autoExpand && !matchedByBranch && !p.expanded) {
                        p.setExpanded(true, {
                            noAnimation: true,
                            noEvents: true,
                            scrollIntoView: false,
                        });
                        p._filterAutoExpanded = true;
                    }
                }, true);
            }
        });
        treeOpts.autoCollapse = prevAutoCollapse;

        if (count === 0 && opts.nodata && hideMode) {
            statusNode = opts.nodata;
            if (typeof statusNode === "function") {
                statusNode = statusNode();
            }
            if (statusNode === true) {
                statusNode = {};
            } else if (typeof statusNode === "string") {
                statusNode = { title: statusNode };
            }
            statusNode = $.extend(
                {
                    statusNodeType: "nodata",
                    key: KeyNoData,
                    title: this.options.strings.noData,
                },
                statusNode
            );

            this.getRootNode().addNode(statusNode).match = true;
        }
        // Redraw whole tree
        this._callHook("treeStructureChanged", this, "applyFilter");
        // this.render();
        this.enableUpdate(prevEnableUpdate);
        return count;
    };

    /**
     * [ext-filter] Dimm or hide nodes.
     *
     * @param {function | string} filter
     * @param {boolean} [opts={autoExpand: false, leavesOnly: false}]
     * @returns {integer} count
     * @alias Fancytree#filterNodes
     * @requires jquery.fancytree.filter.js
     */
    $.ui.fancytree._FancytreeClass.prototype.filterNodes = function (
        filter,
        opts
    ) {
        if (typeof opts === "boolean") {
            opts = { leavesOnly: opts };
            this.warn(
                "Fancytree.filterNodes() leavesOnly option is deprecated since 2.9.0 / 2015-04-19. Use opts.leavesOnly instead."
            );
        }
        return this._applyFilterImpl(filter, false, opts);
    };

    /**
     * [ext-filter] Dimm or hide whole branches.
     *
     * @param {function | string} filter
     * @param {boolean} [opts={autoExpand: false}]
     * @returns {integer} count
     * @alias Fancytree#filterBranches
     * @requires jquery.fancytree.filter.js
     */
    $.ui.fancytree._FancytreeClass.prototype.filterBranches = function (
        filter,
        opts
    ) {
        return this._applyFilterImpl(filter, true, opts);
    };

    /**
     * [ext-filter] Re-apply current filter.
     *
     * @returns {integer} count
     * @alias Fancytree#updateFilter
     * @requires jquery.fancytree.filter.js
     * @since 2.38
     */
    $.ui.fancytree._FancytreeClass.prototype.updateFilter = function () {
        if (
            this.enableFilter &&
            this.lastFilterArgs &&
            this.options.filter.autoApply
        ) {
            this._applyFilterImpl.apply(this, this.lastFilterArgs);
        } else {
            this.warn("updateFilter(): no filter active.");
        }
    };

    /**
     * [ext-filter] Reset the filter.
     *
     * @alias Fancytree#clearFilter
     * @requires jquery.fancytree.filter.js
     */
    $.ui.fancytree._FancytreeClass.prototype.clearFilter = function () {
        var $title,
            statusNode = this.getRootNode()._findDirectChild(KeyNoData),
            escapeTitles = this.options.escapeTitles,
            enhanceTitle = this.options.enhanceTitle,
            prevEnableUpdate = this.enableUpdate(false);

        if (statusNode) {
            statusNode.remove();
        }
        // we also counted root node's subMatchCount
        delete this.rootNode.match;
        delete this.rootNode.subMatchCount;

        this.visit(function (node) {
            if (node.match && node.span) {
                // #491, #601
                $title = $(node.span).find(">span.fancytree-title");
                if (escapeTitles) {
                    $title.text(node.title);
                } else {
                    $title.html(node.title);
                }
                if (enhanceTitle) {
                    enhanceTitle(
                        { type: "enhanceTitle" },
                        { node: node, $title: $title }
                    );
                }
            }
            delete node.match;
            delete node.subMatchCount;
            delete node.titleWithHighlight;
            if (node.$subMatchBadge) {
                node.$subMatchBadge.remove();
                delete node.$subMatchBadge;
            }
            if (node._filterAutoExpanded && node.expanded) {
                node.setExpanded(false, {
                    noAnimation: true,
                    noEvents: true,
                    scrollIntoView: false,
                });
            }
            delete node._filterAutoExpanded;
        });
        this.enableFilter = false;
        this.lastFilterArgs = null;
        this.$div.removeClass(
            "fancytree-ext-filter fancytree-ext-filter-dimm fancytree-ext-filter-hide"
        );
        this._callHook("treeStructureChanged", this, "clearFilter");
        // this.render();
        this.enableUpdate(prevEnableUpdate);
    };

    /**
     * [ext-filter] Return true if a filter is currently applied.
     *
     * @returns {Boolean}
     * @alias Fancytree#isFilterActive
     * @requires jquery.fancytree.filter.js
     * @since 2.13
     */
    $.ui.fancytree._FancytreeClass.prototype.isFilterActive = function () {
        return !!this.enableFilter;
    };

    /**
     * [ext-filter] Return true if this node is matched by current filter (or no filter is active).
     *
     * @returns {Boolean}
     * @alias FancytreeNode#isMatched
     * @requires jquery.fancytree.filter.js
     * @since 2.13
     */
    $.ui.fancytree._FancytreeNodeClass.prototype.isMatched = function () {
        return !(this.tree.enableFilter && !this.match);
    };

    /*******************************************************************************
     * Extension code
     */
    $.ui.fancytree.registerExtension({
        name: "filter",
        version: "@VERSION",
        // Default options for this extension.
        options: {
            autoApply: true, // Re-apply last filter if lazy data is loaded
            autoExpand: false, // Expand all branches that contain matches while filtered
            counter: true, // Show a badge with number of matching child nodes near parent icons
            fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar'
            hideExpandedCounter: true, // Hide counter badge if parent is expanded
            hideExpanders: false, // Hide expanders if all child nodes are hidden by filter
            highlight: true, // Highlight matches by wrapping inside <mark> tags
            leavesOnly: false, // Match end nodes only
            nodata: true, // Display a 'no data' status node if result is empty
            mode: "dimm", // Grayout unmatched nodes (pass "hide" to remove unmatched node instead)
        },
        nodeLoadChildren: function (ctx, source) {
            var tree = ctx.tree;

            return this._superApply(arguments).done(function () {
                if (
                    tree.enableFilter &&
                    tree.lastFilterArgs &&
                    ctx.options.filter.autoApply
                ) {
                    tree._applyFilterImpl.apply(tree, tree.lastFilterArgs);
                }
            });
        },
        nodeSetExpanded: function (ctx, flag, callOpts) {
            var node = ctx.node;

            delete node._filterAutoExpanded;
            // Make sure counter badge is displayed again, when node is beeing collapsed
            if (
                !flag &&
                ctx.options.filter.hideExpandedCounter &&
                node.$subMatchBadge
            ) {
                node.$subMatchBadge.show();
            }
            return this._superApply(arguments);
        },
        nodeRenderStatus: function (ctx) {
            // Set classes for current status
            var res,
                node = ctx.node,
                tree = ctx.tree,
                opts = ctx.options.filter,
                $title = $(node.span).find("span.fancytree-title"),
                $span = $(node[tree.statusClassPropName]),
                enhanceTitle = ctx.options.enhanceTitle,
                escapeTitles = ctx.options.escapeTitles;

            res = this._super(ctx);
            // nothing to do, if node was not yet rendered
            if (!$span.length || !tree.enableFilter) {
                return res;
            }
            $span
                .toggleClass("fancytree-match", !!node.match)
                .toggleClass("fancytree-submatch", !!node.subMatchCount)
                .toggleClass(
                    "fancytree-hide",
                    !(node.match || node.subMatchCount)
                );
            // Add/update counter badge
            if (
                opts.counter &&
                node.subMatchCount &&
                (!node.isExpanded() || !opts.hideExpandedCounter)
            ) {
                if (!node.$subMatchBadge) {
                    node.$subMatchBadge = $(
                        "<span class='fancytree-childcounter'/>"
                    );
                    $(
                        "span.fancytree-icon, span.fancytree-custom-icon",
                        node.span
                    ).append(node.$subMatchBadge);
                }
                node.$subMatchBadge.show().text(node.subMatchCount);
            } else if (node.$subMatchBadge) {
                node.$subMatchBadge.hide();
            }
            // node.debug("nodeRenderStatus", node.titleWithHighlight, node.title)
            // #601: also check for $title.length, because we don't need to render
            // if node.span is null (i.e. not rendered)
            if (node.span && (!node.isEditing || !node.isEditing.call(node))) {
                if (node.titleWithHighlight) {
                    $title.html(node.titleWithHighlight);
                } else if (escapeTitles) {
                    $title.text(node.title);
                } else {
                    $title.html(node.title);
                }
                if (enhanceTitle) {
                    enhanceTitle(
                        { type: "enhanceTitle" },
                        { node: node, $title: $title }
                    );
                }
            }
            return res;
        },
    });
    // Value returned by `require('jquery.fancytree..')`
    return $.ui.fancytree;
}); // End of closure