/* * angular-google-places-autocomplete * * Copyright (c) 2014 "kuhnza" David Kuhn * Licensed under the MIT license. * https://github.com/kuhnza/angular-google-places-autocomplete/blob/master/LICENSE */ 'use strict'; angular.module('google.places', []) /** * DI wrapper around global google places library. * * Note: requires the Google Places API to already be loaded on the page. */ .factory('googlePlacesApi', ['$window', function ($window) { if (!$window.google) throw 'Global `google` var missing. Did you forget to include the places API script?'; return $window.google; }]) /** * Autocomplete directive. Use like this: * * */ .directive('gPlacesAutocomplete', [ '$parse', '$compile', '$timeout', '$document', 'googlePlacesApi', function ($parse, $compile, $timeout, $document, google) { return { restrict: 'A', require: '^ngModel', scope: { model: '=ngModel', options: '=?', forceSelection: '=?', customPlaces: '=?' }, controller: ['$scope', function ($scope) {}], link: function ($scope, element, attrs, controller) { var keymap = { tab: 9, enter: 13, esc: 27, up: 38, down: 40 }, hotkeys = [keymap.tab, keymap.enter, keymap.esc, keymap.up, keymap.down], autocompleteService = new google.maps.places.AutocompleteService(), placesService = new google.maps.places.PlacesService(element[0]); (function init() { $scope.query = ''; $scope.predictions = []; $scope.input = element; $scope.options = $scope.options || {}; initAutocompleteDrawer(); initEvents(); initNgModelController(); }()); function initEvents() { element.bind('keydown', onKeydown); element.bind('blur', onBlur); element.bind('submit', onBlur); $scope.$watch('selected', select); } function initAutocompleteDrawer() { // Drawer element used to display predictions var drawerElement = angular.element('
'), body = angular.element($document[0].body), $drawer; drawerElement.attr({ input: 'input', query: 'query', predictions: 'predictions', active: 'active', selected: 'selected' }); $drawer = $compile(drawerElement)($scope); body.append($drawer); // Append to DOM $scope.$on('$destroy', function() { $drawer.remove(); }); } function initNgModelController() { controller.$parsers.push(parse); controller.$formatters.push(format); controller.$render = render; } function onKeydown(event) { if ($scope.predictions.length === 0 || indexOf(hotkeys, event.which) === -1) { return; } event.preventDefault(); if (event.which === keymap.down) { $scope.active = ($scope.active + 1) % $scope.predictions.length; $scope.$digest(); } else if (event.which === keymap.up) { $scope.active = ($scope.active ? $scope.active : $scope.predictions.length) - 1; $scope.$digest(); } else if (event.which === 13 || event.which === 9) { if ($scope.forceSelection) { $scope.active = ($scope.active === -1) ? 0 : $scope.active; } $scope.$apply(function () { $scope.selected = $scope.active; if ($scope.selected === -1) { clearPredictions(); } }); } else if (event.which === 27) { $scope.$apply(function () { event.stopPropagation(); clearPredictions(); }); } } function onBlur(event) { if ($scope.predictions.length === 0) { return; } if ($scope.forceSelection) { $scope.selected = ($scope.selected === -1) ? 0 : $scope.selected; } $scope.$digest(); $scope.$apply(function () { if ($scope.selected === -1) { clearPredictions(); } }); } function select() { var prediction; prediction = $scope.predictions[$scope.selected]; if (!prediction) return; if (prediction.is_custom) { $scope.$apply(function () { $scope.model = prediction.place; $scope.$emit('g-places-autocomplete:select', prediction.place); $timeout(function () { controller.$viewChangeListeners.forEach(function (fn) { fn(); }); }); }); } else { placesService.getDetails({ placeId: prediction.place_id }, function (place, status) { if (status == google.maps.places.PlacesServiceStatus.OK) { $scope.$apply(function () { $scope.model = place; $scope.$emit('g-places-autocomplete:select', place); $timeout(function () { controller.$viewChangeListeners.forEach(function (fn) { fn(); }); }); }); } }); } clearPredictions(); } function parse(viewValue) { var request; if (!(viewValue && isString(viewValue))) return viewValue; $scope.query = viewValue; request = angular.extend({ input: viewValue }, $scope.options); autocompleteService.getPlacePredictions(request, function (predictions, status) { $timeout(function () { var customPlacePredictions; clearPredictions(); if ($scope.customPlaces) { customPlacePredictions = getCustomPlacePredictions($scope.query); $scope.predictions.push.apply($scope.predictions, customPlacePredictions); } if (status == google.maps.places.PlacesServiceStatus.OK) { $scope.predictions.push.apply($scope.predictions, predictions); } if ($scope.predictions.length > 5) { $scope.predictions.length = 5; // trim predictions down to size } }); }); if ($scope.forceSelection) { return controller.$modelValue; } else { return viewValue; } } function format(modelValue) { var viewValue = ""; if (isString(modelValue)) { viewValue = modelValue; } else if (isObject(modelValue)) { viewValue = modelValue.formatted_address; } return viewValue; } function render() { return element.val(controller.$viewValue); } function clearPredictions() { $scope.active = -1; $scope.selected = -1; $scope.predictions = []; } function getCustomPlacePredictions(query) { var predictions = [], place, match, i; for (i = 0; i < $scope.customPlaces.length; i++) { place = $scope.customPlaces[i]; match = getCustomPlaceMatches(query, place); if (match.matched_substrings.length > 0) { predictions.push({ is_custom: true, custom_prediction_label: place.custom_prediction_label || '(Custom Non-Google Result)', // required by https://developers.google.com/maps/terms ยง 10.1.1 (d) description: place.formatted_address, place: place, matched_substrings: match.matched_substrings, terms: match.terms }); } } return predictions; } function getCustomPlaceMatches(query, place) { var q = query + '', // make a copy so we don't interfere with subsequent matches terms = [], matched_substrings = [], fragment, termFragments, i; termFragments = place.formatted_address.split(','); for (i = 0; i < termFragments.length; i++) { fragment = termFragments[i].trim(); if (q.length > 0) { if (fragment.length >= q.length) { if (startsWith(fragment, q)) { matched_substrings.push({ length: q.length, offset: i }); } q = ''; // no more matching to do } else { if (startsWith(q, fragment)) { matched_substrings.push({ length: fragment.length, offset: i }); q = q.replace(fragment, '').trim(); } else { q = ''; // no more matching to do } } } terms.push({ value: fragment, offset: place.formatted_address.indexOf(fragment) }); } return { matched_substrings: matched_substrings, terms: terms }; } function isString(val) { return Object.prototype.toString.call(val) == '[object String]'; } function isObject(val) { return Object.prototype.toString.call(val) == '[object Object]'; } function indexOf(array, item) { var i, length; if (array === null) return -1; length = array.length; for (i = 0; i < length; i++) { if (array[i] === item) return i; } return -1; } function startsWith(string1, string2) { return toLower(string1).lastIndexOf(toLower(string2), 0) === 0; } function toLower(string) { return (string === null) ? "" : string.toLowerCase(); } } }; } ]) .directive('gPlacesAutocompleteDrawer', ['$window', '$document', function ($window, $document) { var TEMPLATE = [ '
', '
', '
', '
' ]; return { restrict: 'A', scope:{ input: '=', query: '=', predictions: '=', active: '=', selected: '=' }, template: TEMPLATE.join(''), link: function ($scope, element) { element.bind('mousedown', function (event) { event.preventDefault(); // prevent blur event from firing when clicking selection }); $window.onresize = function () { $scope.$apply(function () { $scope.position = getDrawerPosition($scope.input); }); }; $scope.isOpen = function () { return $scope.predictions.length > 0; }; $scope.isActive = function (index) { return $scope.active === index; }; $scope.selectActive = function (index) { $scope.active = index; }; $scope.selectPrediction = function (index) { $scope.selected = index; }; $scope.$watch('predictions', function () { $scope.position = getDrawerPosition($scope.input); }, true); function getDrawerPosition(element) { var domEl = element[0], rect = domEl.getBoundingClientRect(), docEl = $document[0].documentElement, body = $document[0].body, scrollTop = $window.pageYOffset || docEl.scrollTop || body.scrollTop, scrollLeft = $window.pageXOffset || docEl.scrollLeft || body.scrollLeft; return { width: rect.width, height: rect.height, top: rect.top + rect.height + scrollTop, left: rect.left + scrollLeft }; } } }; }]) .directive('gPlacesAutocompletePrediction', [function () { var TEMPLATE = [ '', '', '{{term.value | trailingComma:!$last}} ', ' {{prediction.custom_prediction_label}}' ]; return { restrict: 'A', scope:{ index:'=', prediction:'=', query:'=' }, template: TEMPLATE.join('') }; }]) .filter('highlightMatched', ['$sce', function ($sce) { return function (prediction) { var matchedPortion = '', unmatchedPortion = '', matched; if (prediction.matched_substrings.length > 0 && prediction.terms.length > 0) { matched = prediction.matched_substrings[0]; matchedPortion = prediction.terms[0].value.substr(matched.offset, matched.length); unmatchedPortion = prediction.terms[0].value.substr(matched.offset + matched.length); } return $sce.trustAsHtml('' + matchedPortion + '' + unmatchedPortion); }; }]) .filter('unmatchedTermsOnly', [function () { return function (terms, prediction) { var i, term, filtered = []; for (i = 0; i < terms.length; i++) { term = terms[i]; if (prediction.matched_substrings.length > 0 && term.offset > prediction.matched_substrings[0].length) { filtered.push(term); } } return filtered; }; }]) .filter('trailingComma', [function () { return function (input, condition) { return (condition) ? input + ',' : input; }; }]);