承诺不在自定义TypeAhead指令中解析

时间:2015-07-14 22:39:50

标签: javascript angularjs promise angular-ui angular-ui-typeahead

我编辑了TypeAhead指令,该指令是AngularJS的Angular UI的一部分,因此它只会根据最近的单词提供建议,由空格分隔("")。

我打算将它用于类似查询构建器的东西,根据周围的语法动态提供建议。这符合第一个单词的预期,但是一旦我们得到第二个单词,承诺就不再由于某种原因而解决了。 inputValue的值是正确的,正如预期的那样,但

中的代码

$q.when(parserResult.source(originalScope, locals)).then(function (matches) {

似乎没有运行。请指教。

我的代码(与原版完全相同,只是我添加了一个名为getLastWord的函数来截断当前表达式:

angular.module('customTypeahead', ['ui.bootstrap.position', 'ui.bootstrap.bindHtml'])

.factory('customTypeaheadParser', ['$parse', function ($parse) {

    //                      00000111000000000000022200000000000000003333333333333330000000000044000
    var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/;

    return {
        parse: function (input) {

            var match = input.match(TYPEAHEAD_REGEXP);
            if (!match) {
                throw new Error(
                    'Expected customTypeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' +
                    ' but got "' + input + '".');
            }

            return {
                itemName: match[3],
                source: $parse(match[4]),
                viewMapper: $parse(match[2] || match[1]),
                modelMapper: $parse(match[1])
            };
        }
    };
}])

.directive('customTypeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$rootScope', '$position', 'customTypeaheadParser',
    function ($compile, $parse, $q, $timeout, $document, $rootScope, $position, customTypeaheadParser) {

        var HOT_KEYS = [9, 13, 27, 38, 40];

        return {
            require: 'ngModel',
            link: function (originalScope, element, attrs, modelCtrl) {

                //SUPPORTED ATTRIBUTES (OPTIONS)

                //minimal no of characters that needs to be entered before customTypeahead kicks-in
                var minLength = originalScope.$eval(attrs.customTypeaheadMinLength);
                if (!minLength && minLength !== 0) {
                    minLength = 0;
                }

                //minimal wait time after last character typed before customTypeahead kicks-in
                var waitTime = originalScope.$eval(attrs.customTypeaheadWaitMs) || 0;

                //should it restrict model values to the ones selected from the popup only?
                var isEditable = originalScope.$eval(attrs.customTypeaheadEditable) !== false;

                //binding to a variable that indicates if matches are being retrieved asynchronously
                var isLoadingSetter = $parse(attrs.customTypeaheadLoading).assign || angular.noop;

                //a callback executed when a match is selected
                var onSelectCallback = $parse(attrs.customTypeaheadOnSelect);

                var inputFormatter = attrs.customTypeaheadInputFormatter ? $parse(attrs.customTypeaheadInputFormatter) : undefined;

                var appendToBody = attrs.customTypeaheadAppendToBody ? originalScope.$eval(attrs.customTypeaheadAppendToBody) : false;

                var focusFirst = originalScope.$eval(attrs.customTypeaheadFocusFirst) !== false;

                //INTERNAL VARIABLES

                //model setter executed upon match selection
                var $setModelValue = $parse(attrs.ngModel).assign;

                //expressions used by customTypeahead
                var parserResult = customTypeaheadParser.parse(attrs.customTypeahead);

                var hasFocus;

                //create a child scope for the customTypeahead directive so we are not polluting original scope
                //with customTypeahead-specific data (matches, query etc.)
                var scope = originalScope.$new();
                originalScope.$on('$destroy', function () {
                    scope.$destroy();
                });

                // WAI-ARIA
                var popupId = 'customTypeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000);
                element.attr({
                    'aria-autocomplete': 'list',
                    'aria-expanded': false,
                    'aria-owns': popupId
                });

                //pop-up element used to display matches
                var popUpEl = angular.element('<div custom-typeahead-popup></div>');
                popUpEl.attr({
                    id: popupId,
                    matches: 'matches',
                    active: 'activeIdx',
                    select: 'select(activeIdx)',
                    query: 'query',
                    position: 'position'
                });
                //custom item template
                if (angular.isDefined(attrs.customTypeaheadTemplateUrl)) {
                    popUpEl.attr('template-url', attrs.customTypeaheadTemplateUrl);
                }

                var resetMatches = function () {
                    scope.matches = [];
                    scope.activeIdx = -1;
                    element.attr('aria-expanded', false);
                };

                var getMatchId = function (index) {
                    return popupId + '-option-' + index;
                };

                // Indicate that the specified match is the active (pre-selected) item in the list owned by this customTypeahead.
                // This attribute is added or removed automatically when the `activeIdx` changes.
                scope.$watch('activeIdx', function (index) {
                    if (index < 0) {
                        element.removeAttr('aria-activedescendant');
                    } else {
                        element.attr('aria-activedescendant', getMatchId(index));
                    }
                });

                var getLastWord = function (expression) {
                    if (expression === "") {
                        return "";
                    }
                    var temp = expression.split(" ");
                    return temp[temp.length - 1];
                };

                var getMatchesAsync = function (inputValue) {
                    inputValue = getLastWord(inputValue);
                    var locals = {$viewValue: inputValue};
                    isLoadingSetter(originalScope, true);
                    $q.when(parserResult.source(originalScope, locals)).then(function (matches) {

                        //it might happen that several async queries were in progress if a user were typing fast
                        //but we are interested only in responses that correspond to the current view value
                        var onCurrentRequest = (inputValue === modelCtrl.$viewValue);
                        if (onCurrentRequest && hasFocus) {
                            if (matches && matches.length > 0) {

                                scope.activeIdx = focusFirst ? 0 : -1;
                                scope.matches.length = 0;

                                //transform labels
                                for (var i = 0; i < matches.length; i++) {
                                    locals[parserResult.itemName] = matches[i];
                                    scope.matches.push({
                                        id: getMatchId(i),
                                        label: parserResult.viewMapper(scope, locals),
                                        model: matches[i]
                                    });
                                }

                                scope.query = inputValue;
                                //position pop-up with matches - we need to re-calculate its position each time we are opening a window
                                //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page
                                //due to other elements being rendered
                                scope.position = appendToBody ? $position.offset(element) : $position.position(element);
                                scope.position.top = scope.position.top + element.prop('offsetHeight');

                                element.attr('aria-expanded', true);
                            } else {
                                resetMatches();
                            }
                        }
                        if (onCurrentRequest) {
                            isLoadingSetter(originalScope, false);
                        }
                    }, function () {
                        resetMatches();
                        isLoadingSetter(originalScope, false);
                    });
                };

                resetMatches();

                //we need to propagate user's query so we can highlight matches
                scope.query = undefined;

                //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later
                var timeoutPromise;

                var scheduleSearchWithTimeout = function (inputValue) {
                    timeoutPromise = $timeout(function () {
                        getMatchesAsync(inputValue);
                    }, waitTime);
                };

                var cancelPreviousTimeout = function () {
                    if (timeoutPromise) {
                        $timeout.cancel(timeoutPromise);
                    }
                };

                //plug into $parsers pipeline to open a customTypeahead on view changes initiated from DOM
                //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue
                modelCtrl.$parsers.unshift(function (inputValue) {
                    inputValue = getLastWord(inputValue);
                    hasFocus = true;

                    if (minLength === 0 || inputValue && inputValue.length >= minLength) {
                        if (waitTime > 0) {
                            cancelPreviousTimeout();
                            scheduleSearchWithTimeout(inputValue);
                        } else {
                            getMatchesAsync(inputValue);
                        }
                    } else {
                        isLoadingSetter(originalScope, false);
                        cancelPreviousTimeout();
                        resetMatches();
                    }

                    if (isEditable) {
                        return inputValue;
                    } else {
                        if (!inputValue) {
                            // Reset in case user had typed something previously.
                            modelCtrl.$setValidity('editable', true);
                            return inputValue;
                        } else {
                            modelCtrl.$setValidity('editable', false);
                            return undefined;
                        }
                    }
                });

                modelCtrl.$formatters.push(function (modelValue) {

                    var candidateViewValue, emptyViewValue;
                    var locals = {};

                    // The validity may be set to false via $parsers (see above) if
                    // the model is restricted to selected values. If the model
                    // is set manually it is considered to be valid.
                    if (!isEditable) {
                        modelCtrl.$setValidity('editable', true);
                    }

                    if (inputFormatter) {

                        locals.$model = modelValue;
                        return inputFormatter(originalScope, locals);

                    } else {

                        //it might happen that we don't have enough info to properly render input value
                        //we need to check for this situation and simply return model value if we can't apply custom formatting
                        locals[parserResult.itemName] = modelValue;
                        candidateViewValue = parserResult.viewMapper(originalScope, locals);
                        locals[parserResult.itemName] = undefined;
                        emptyViewValue = parserResult.viewMapper(originalScope, locals);

                        return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue;
                    }
                });

                scope.select = function (activeIdx) {
                    //called from within the $digest() cycle
                    var locals = {};
                    var model, item;

                    locals[parserResult.itemName] = item = scope.matches[activeIdx].model;
                    model = parserResult.modelMapper(originalScope, locals);
                    $setModelValue(originalScope, model);
                    modelCtrl.$setValidity('editable', true);
                    modelCtrl.$setValidity('parse', true);

                    onSelectCallback(originalScope, {
                        $item: item,
                        $model: model,
                        $label: parserResult.viewMapper(originalScope, locals)
                    });

                    resetMatches();

                    //return focus to the input element if a match was selected via a mouse click event
                    // use timeout to avoid $rootScope:inprog error
                    $timeout(function () {
                        element[0].focus();
                    }, 0, false);
                };

                //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27)
                element.bind('keydown', function (evt) {

                    //customTypeahead is open and an "interesting" key was pressed
                    if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) {
                        return;
                    }

                    // if there's nothing selected (i.e. focusFirst) and enter is hit, don't do anything
                    if (scope.activeIdx == -1 && (evt.which === 13 || evt.which === 9)) {
                        return;
                    }

                    evt.preventDefault();

                    if (evt.which === 40) {
                        scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
                        scope.$digest();

                    } else if (evt.which === 38) {
                        scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1;
                        scope.$digest();

                    } else if (evt.which === 13 || evt.which === 9) {
                        scope.$apply(function () {
                            scope.select(scope.activeIdx);
                        });

                    } else if (evt.which === 27) {
                        evt.stopPropagation();

                        resetMatches();
                        scope.$digest();
                    }
                });

                element.bind('blur', function (evt) {
                    hasFocus = false;
                });

                // Keep reference to click handler to unbind it.
                var dismissClickHandler = function (evt) {
                    if (element[0] !== evt.target) {
                        resetMatches();
                        if (!$rootScope.$$phase) {
                            scope.$digest();
                        }
                    }
                };

                $document.bind('click', dismissClickHandler);

                originalScope.$on('$destroy', function () {
                    $document.unbind('click', dismissClickHandler);
                    if (appendToBody) {
                        $popup.remove();
                    }
                    // Prevent jQuery cache memory leak
                    popUpEl.remove();
                });

                var $popup = $compile(popUpEl)(scope);

                if (appendToBody) {
                    $document.find('body').append($popup);
                } else {
                    element.after($popup);
                }
            }
        };

    }])

.directive('customTypeaheadPopup', function () {
    return {
        restrict: 'EA',
        scope: {
            matches: '=',
            query: '=',
            active: '=',
            position: '&',
            select: '&'
        },
        replace: true,
        templateUrl: 'html/templates/custom-typeahead-popup.html',
        link: function (scope, element, attrs) {

            scope.templateUrl = attrs.templateUrl;

            scope.isOpen = function () {
                return scope.matches.length > 0;
            };

            scope.isActive = function (matchIdx) {
                return scope.active == matchIdx;
            };

            scope.selectActive = function (matchIdx) {
                scope.active = matchIdx;
            };

            scope.selectMatch = function (activeIdx) {
                scope.select({activeIdx: activeIdx});
            };
        }
    };
})

.directive('customTypeaheadMatch', ['$templateRequest', '$compile', '$parse', function ($templateRequest, $compile, $parse) {
    return {
        restrict: 'EA',
        scope: {
            index: '=',
            match: '=',
            query: '='
        },
        link: function (scope, element, attrs) {
            var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'html/templates/custom-typeahead-match.html';
            $templateRequest(tplUrl).then(function (tplContent) {
                $compile(tplContent.trim())(scope, function (clonedElement) {
                    element.replaceWith(clonedElement);
                });
            });
        }
    };
}])

.filter('customTypeaheadHighlight', function () {

    function escapeRegexp(queryToEscape) {
        return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
    }

    return function (matchItem, query) {
        return query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : matchItem;
    };
});

0 个答案:

没有答案