<!DOCTYPE html>
<html>

  <head>
    <link data-require="bootstrap@*" data-semver="3.3.2" rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" />
    <link rel="stylesheet" href="http://urbanoalvarez.es/smart-area/dist/smart-area.css">
    
    <script data-require="jquery@2.1.3" data-semver="2.1.3" src="http://code.jquery.com/jquery-2.1.3.min.js"></script>
    <script data-require="bootstrap@*" data-semver="3.3.2" src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
    <script data-require="angular.js@1.3.8" data-semver="1.3.8" src="https://code.angularjs.org/1.3.8/angular.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.3.8/angular-sanitize.js"></script>
    <!-- <script src="http://urbanoalvarez.es/smart-area/dist/smart-area.js"></script> -->
    <script src="smart-area.js"></script>
    <script src="app.js"></script>
    <style>
      .user{
        color: #0074D9;
      }
    </style>
  </head>

  <body ng-app="myApp">
    <div class="container" ng-controller="DemoController">
      <h3> Demo</h3>
      <!--<h4>@user mentions</h4>-->
      <textarea class="form-control code" rows="5" ng-model="text" smart-area="config"></textarea>
      <hr>
      <small class="text-muted">
        <b>Available users:</b><br> Bret, Antonette, Samantha, Karianne, Kamren, Leopoldo_Corkery, Elwyn.Skiles, Delphine, Maxime_Nienow, Moriah.Stanton <br>
        Type for example "Hey @Antonette"
        <hr>
        <p class="text-center">
          <a href="https://github.com/aurbano/smart-area">Smart Area</a> &bull;
          Demo by <a href="http://urbanoalvarez.es">Alejandro U. Alvarez</a> &bull;
          <em>Test data from jsonplaceholder.typicode.com</em>
        </p>
      </small>
    </div>
  </body>

</html>
# smart area demo
angular.module('myApp', ['smartArea'])
  .controller('DemoController', ['$scope', '$http', function($scope, $http) {
    $scope.text = '';
    
    $scope.config = {
      autocomplete: [
        {
          words: [/@([A-Za-z]+[_A-Za-z0-9]+)/gi],
          cssClass: 'user'
        }
      ],
      dropdown: [
        {
          trigger: /@([A-Za-z]+[_A-Za-z0-9]+)/gi,
          list: function(match, callback){
            
            // match is the regexp return, in this case it returns
            // [0] the full match, [1] the first capture group => username
            $http.get('http://jsonplaceholder.typicode.com/users')
            .success(function(data){
              // Prepare the fake data
              var listData = data.filter(function(element){
                return element.username.substr(0,match[1].length).toLowerCase() === match[1].toLowerCase()
                && element.username.length > match[1].length;
              }).map(function(element){
                return {
                  display: element.username, // This gets displayed in the dropdown
                  item: element // This will get passed to onSelect
                };
              });
              callback(listData);
            }).error(function(err){
              console.error(err);
            });
          },
          onSelect: function(item){
            return item.display;
          },
          mode: 'replace'
        }
      ]
    };
  }]);
/**
 * AngularJS Directive to allow autocomplete and dropdown suggestions
 * on textareas.
 *
 * Homepage: https://github.com/aurbano/smart-area
 *
 * @version 1.0.2
 * @author Alejandro U. Alvarez (http://urbanoalvarez.es)
 * @license AGPLv3 (See LICENSE)
 */

angular.module('smartArea', [])
    .directive('smartArea', function($compile) {
    return {
        restrict: 'A',
        scope: {
            areaConfig: '=smartArea',
            areaData: '=ngModel'
        },
        replace: true,
        link: function(scope, textArea){
            if(textArea[0].tagName.toLowerCase() !== 'textarea'){
                console.warn("smartArea can only be used on textareas");
                return false;
            }

            // Caret tracking inspired by
            // https://github.com/component/textarea-caret-position
            // Properties to be copied over from the textarea
            var properties = [
                'direction',  // RTL support
                'boxSizing',
                'width',  // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
                'overflowX',
                'overflowY',  // copy the scrollbar for IE
                'color',
                'height',

                'borderTopWidth',
                'borderRightWidth',
                'borderBottomWidth',
                'borderLeftWidth',

                'borderTopColor',
                'borderRightColor',
                'borderBottomColor',
                'borderLeftColor',

                'borderTopStyle',
                'borderRightStyle',
                'borderBottomStyle',
                'borderLeftStyle',
                'borderRadius',

                'backgroundColor',

                'paddingTop',
                'paddingRight',
                'paddingBottom',
                'paddingLeft',

                // https://developer.mozilla.org/en-US/docs/Web/CSS/font
                'fontStyle',
                'fontVariant',
                'fontWeight',
                'fontStretch',
                'fontSize',
                'fontSizeAdjust',
                'lineHeight',
                'fontFamily',

                'textAlign',
                'textTransform',
                'textIndent',
                'textDecoration',  // might not make a difference, but better be safe

                'letterSpacing',
                'wordSpacing',
                'whiteSpace',
                'wordBreak',
                'wordWrap'
            ];

            // Build the HTML structure
            var mainWrap = angular.element('<div class="sa-wrapper"></div>'),
                isFirefox = !(window.mozInnerScreenX === null);

            scope.fakeAreaElement = angular.element($compile('<div class="sa-fakeArea" ng-trim="false" ng-bind-html="fakeArea"></div>')(scope))
                .appendTo(mainWrap);

            scope.dropdown.element = angular.element($compile('<div class="sa-dropdown" ng-show="dropdown.content.length > 0"><input type="text" class="form-control" ng-model="dropdown.filter" ng-show="dropdown.showFilter"/><ul class="dropdown-menu" role="menu" style="position:static"><li ng-repeat="element in dropdown.content | filter:dropdown.filter" role="presentation"><a href="" role="menuitem" ng-click="dropdown.selected(element)" ng-class="{active: $index == dropdown.current}" ng-bind-html="element.display"></a></li></ul></div>')(scope))
                .appendTo(mainWrap);

            scope.dropdown.filterElement = scope.dropdown.element.find('input');
            scope.dropdown.filterElement.bind('keydown', scope.keyboardEvents);

            // Default textarea css for the div
            scope.fakeAreaElement.css('whiteSpace', 'pre-wrap');
            scope.fakeAreaElement.css('wordWrap', 'break-word');

            // Transfer the element's properties to the div
            properties.forEach(function (prop) {
                scope.fakeAreaElement.css(prop, textArea.css(prop));
            });

            scope.fakeAreaElement.css('width',(parseInt(textArea.outerWidth()) + 1) + 'px');

            // Special considerations for Firefox
//            if (isFirefox) {
//                scope.fakeAreaElement.css('width',parseInt(textArea.width()) - 2 + 'px');  // Firefox adds 2 pixels to the padding - https://bugzilla.mozilla.org/show_bug.cgi?id=753662
//                // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
//                if (textArea.scrollHeight > parseInt(textArea.height)){
//                    scope.fakeAreaElement.css('overflowY', 'scroll');
//                }
//            }

            // Insert the HTML elements
            mainWrap.insertBefore(textArea);
            textArea.appendTo(mainWrap).addClass('sa-realArea').attr('ng-trim',false);
            $compile(textArea);

            // Dirty hack to maintain the height
            textArea.on('keyup', function(){
                scope.fakeAreaElement.height(textArea.height());
            });

            return mainWrap;
        },
        controller: ['$scope', '$element', '$timeout', '$sce', function($scope, $element, $timeout, $sce){
            /* +----------------------------------------------------+
             * +                     Scope Data                     +
             * +----------------------------------------------------+ */
             
            $scope.fakeArea = $scope.areaData;
            $scope.dropdownContent = 'Dropdown';
            $scope.dropdown = {
                content: [],
                element: null,
                current: 0,
                select: null,
                customSelect: null,
                filter: '',
                match: '',
                mode: 'append',
                showFilter: false,
                filterElement: null
            };

            /* +----------------------------------------------------+
             * +                   Scope Watches                    +
             * +----------------------------------------------------+ */
            $scope.$watch('dropdown.filter', function(){
                $scope.dropdown.current = 0;
            });

            $scope.$watch('areaData', function(){
                $scope.trackCaret();

                // TODO Track caret on another fake area, so I don't have to recalculate autocomplete triggers every time the cursor moves.
                checkTriggers();
            });

            /* +----------------------------------------------------+
             * +                  Scope Functions                   +
             * +----------------------------------------------------+ */

            /**
             * Update the Dropdown position according to the current caret position
             * on the textarea
             */
            $scope.trackCaret = function(){
                var text = $scope.areaData,
                    position = getCharacterPosition();

                $scope.fakeArea = $sce.trustAsHtml(text.substring(0, position) + '<span class="sa-tracking"></span>' + text.substring(position));
                
                // Tracking span
                $timeout(function(){
                    var span = $scope.fakeAreaElement.find('span.sa-tracking');
                    if(span.length > 0){
                        var spanOffset = span.position();
                        // Move the dropdown
                        $scope.dropdown.element.css({
                            top: (spanOffset.top + parseInt($element.css('fontSize')) + 2)+'px',
                            left: (spanOffset.left)+'px'
                        });
                    }
                    highlightText();
                }, 0);
            };

            /**
             * Keyboard event reacting. This function is triggered by
             * keydown events in the dropdown filter and the main textarea
             *
             * @param event JavaScript event
             */
            $scope.keyboardEvents = function(event){
                if($scope.dropdown.content.length > 0) {
                    var code = event.keyCode || event.which;
                    if (code === 13) { // Enter
                        event.preventDefault();
                        event.stopPropagation();
                        // Add the selected word from the Dropdown
                        // to the areaData in the current position
                        $timeout(function(){
                            $scope.dropdown.selected($scope.dropdown.content[$scope.dropdown.current]);
                        },0);
                    }else if(code === 38){ // Up
                        event.preventDefault();
                        event.stopPropagation();
                        $timeout(function(){
                            $scope.dropdown.current--;
                            if($scope.dropdown.current < 0){
                                $scope.dropdown.current = $scope.dropdown.content.length - 1; // Wrap around
                            }
                        },0);
                    }else if(code === 40){ // Down
                        event.preventDefault();
                        event.stopPropagation();
                        $timeout(function(){
                            $scope.dropdown.current++;
                            if($scope.dropdown.current >= $scope.dropdown.content.length){
                                $scope.dropdown.current = 0; // Wrap around
                            }
                        },0);
                    }else if(code === 27){ // Esc
                        event.preventDefault();
                        event.stopPropagation();
                        $timeout(function(){
                            $scope.dropdown.content = [];
                            $element[0].focus();
                        },0);
                    }else if(code === 8){ // Backspace
                        if($scope.dropdown.filter.length < 1){
                            $timeout(function(){
                                $scope.dropdown.content = [];
                                $element[0].focus();
                            },0);
                        }else{
                            event.stopPropagation();
                        }
                    }else{
                        $scope.dropdown.filterElement.focus();
                    }
                }
            };

            /**
             * Add an item to the textarea, this is called
             * when selecting an element from the dropdown.
             * @param item Selected object
             */
            $scope.dropdown.selected = function(item){
                if($scope.dropdown.customSelect !== null){
                    var append = $scope.dropdown.mode === 'append';
                    addSelectedDropdownText($scope.dropdown.customSelect(item), append);
                }else{
                    addSelectedDropdownText(item.display);
                }
                $scope.dropdown.content = [];
            };

            /* +----------------------------------------------------+
             * +                Internal Functions                  +
             * +----------------------------------------------------+ */

            /**
             * Add text to the textarea, this handles positioning the text
             * at the caret position, and also either replacing the last word
             * or appending as new content.
             *
             * @param selectedWord Word to add to the textarea
             * @param append Whether it should be appended or replace the last word
             */
            function addSelectedDropdownText(selectedWord, append){

                $scope.dropdown.showFilter = false;
                $scope.dropdown.filter = '';

                var text = $scope.areaData,
                    position = getCharacterPosition(),
                    lastWord = text.substr(0, position).split(/[\s\b{}]/),
                    remove = lastWord[lastWord.length - 1].length;

                if(!append && $scope.dropdown.match){
                  remove = $scope.dropdown.match.length;
                }

                if(append || remove < 0){
                    remove = 0;
                }

                // Now remove the last word, and replace with the dropped down one
                $scope.areaData = text.substr(0, position - remove) +
                    selectedWord +
                    text.substr(position);
                    
                if(!append && $scope.dropdown.match){
                  position = position - $scope.dropdown.match.length + selectedWord.toString().length;
                }

                // Now reset the caret position
                if($element[0].selectionStart) {
                    $timeout(function(){
                        $element[0].focus();
                        $element[0].setSelectionRange(position - remove + selectedWord.toString().length, position - remove + selectedWord.toString().length);
                        checkTriggers();
                    }, 100);
                }

            }

            /**
             * Perform the "syntax" highlighting of autocomplete words that have
             * a cssClass specified.
             */
            function highlightText(){
                var text = $scope.areaData;

                if(typeof($scope.areaConfig.autocomplete) === 'undefined' || $scope.areaConfig.autocomplete.length === 0){
                    return;
                }

                $scope.areaConfig.autocomplete.forEach(function(autoList){
                    for(var i=0; i<autoList.words.length; i++){
                        if(typeof(autoList.words[i]) === "string"){
                            text = text.replace(new RegExp("([^\\w]|\\b)("+autoList.words[i]+")([^\\w]|\\b)", 'g'), '$1<span class="'+autoList.cssClass+'">$2</span>$3');
                        }else{
                            text = text.replace(autoList.words[i], function(match){
                                return '<span class="'+autoList.cssClass+'">'+match+'</span>';
                            });
                        }
                    }
                });
                // Add to the fakeArea
                $scope.fakeArea = $sce.trustAsHtml(text);
            }

            /**
             * Check all the triggers
             */
            function checkTriggers(){
                triggerDropdownAutocomplete();
                triggerDropdownAdvanced();
            }

            /**
             * Trigger the advanced dropdown system, this will check
             * all the specified triggers in the configuration object under dropdown,
             * and if any of them match it will call it's list() function and add the
             * elements returned from it to the dropdown.
             */
            function triggerDropdownAdvanced(){
                $scope.dropdown.showFilter = false;
                $scope.dropdown.match = false;

                if(typeof($scope.areaConfig.dropdown) === 'undefined' || $scope.areaConfig.dropdown.length === 0){
                    return;
                }

                $scope.areaConfig.dropdown.forEach(function(element){
                    // Check if the trigger is under the cursor
                    var text = $scope.areaData,
                        position = getCharacterPosition();
                    if(typeof(element.trigger) === 'string' && element.trigger === text.substr(position - element.trigger.length, element.trigger.length)){
                        // The cursor is exactly at the end of the trigger

                        element.list(function(data){
                            $scope.dropdown.content = data.map(function(el){
                              el.display = $sce.trustAsHtml(el.display);
                              return el;
                            });

                            $scope.dropdown.customSelect = element.onSelect;
                            $scope.dropdown.mode = element.mode || 'append';
                            $scope.dropdown.match = '';
                            $scope.dropdown.showFilter = element.filter || false;

                            $timeout(function(){
                                $scope.dropdown.filterElement.focus();
                            }, 10);
                        });
                    }else if(typeof(element.trigger) === 'object'){
                        // I need to get the index of the last match
                        var searchable = text.substr(0, position),
                            match, found = false, lastPosition = 0;
                        while ((match = element.trigger.exec(searchable)) !== null){
                            if(match.index === lastPosition){
                                break;
                            }
                            lastPosition = match.index;
                            if(match.index + match[0].length === position){
                                found = true;
                                break;
                            }
                        }
                        if(found){
                            element.list(match, function(data){
                                $scope.dropdown.content = data.map(function(el){
                                  el.display = $sce.trustAsHtml(el.display);
                                  return el;
                                });

                                $scope.dropdown.customSelect = element.onSelect;
                                $scope.dropdown.mode = element.mode || 'append';
                                $scope.dropdown.match = match[1];
                                $scope.dropdown.showFilter = element.filter || false;
                            });

                        }
                    }
                });
            }
            
            /**
             * Set the scroll on the fake area
             */ 
            function resetScroll(){
              $timeout(function(){
                $scope.fakeAreaElement.scrollTop($element.scrollTop());
              }, 5);
            }

            /**
             * Trigger a simple autocomplete, this checks the last word and determines
             * whether any word on the autocomplete lists matches it
             */
            function triggerDropdownAutocomplete(){
                // First check with the autocomplete words (the ones that are not objects
                var autocomplete = [],
                    suggestions = [],
                    text = $scope.areaData,
                    position = getCharacterPosition(),
                    lastWord = text.substr(0, position).split(/[\s\b{}]/);

                // Get the last typed word
                lastWord = lastWord[lastWord.length-1];

                $scope.areaConfig.autocomplete.forEach(function(autoList){
                    autoList.words.forEach(function(word){
                        if(typeof(word) === 'string' && autocomplete.indexOf(word) < 0){
                            if(lastWord.length > 0 || lastWord.length < 1 && autoList.autocompleteOnSpace){
                                autocomplete.push(word);
                            }
                        }
                    });
                });

                $scope.areaConfig.dropdown.forEach(function(element){
                    if(typeof(element.trigger) === 'string' && autocomplete.indexOf(element.trigger) < 0){
                        autocomplete.push(element.trigger);
                    }
                });

                // Now with the list, filter and return
                autocomplete.forEach(function(word){
                    if(lastWord.length < word.length && word.toLowerCase().substr(0, lastWord.length) === lastWord.toLowerCase()){
                        suggestions.push({
                            display: word,
                            data: null
                        });
                    }
                });

                $scope.dropdown.customSelect = null;
                $scope.dropdown.current = 0;
                $scope.dropdown.content = suggestions;
            }
            
            /**
             * Get Character count on an editable field
             * http://stackoverflow.com/questions/4767848/get-caret-cursor-position-in-contenteditable-area-containing-html-content
             */
            function getCharacterPosition() {
              var el = $element[0];
              if (typeof(el.selectionEnd) == "number") {
                return el.selectionEnd; 
              }
          }

            /* +----------------------------------------------------+
             * +                   Event Binding                    +
             * +----------------------------------------------------+ */

            $element.bind('keyup click focus', function () {
                $timeout(function(){
                    $scope.trackCaret();
                }, 0);
            });

            $element.bind('keydown', function(event){
              resetScroll();
              $scope.keyboardEvents(event);
            });
        }]
    };
});