UNPKG

angular-ui-mention

Version:

Facebook-like @mentions for text inputs built around composability

398 lines (339 loc) 11.4 kB
'use strict'; var _slicedToArray = (function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i['return']) _i['return'](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError('Invalid attempt to destructure non-iterable instance'); } }; })(); angular.module('ui.mention', []).directive('uiMention', function () { return { require: ['ngModel', 'uiMention'], controller: 'uiMention', controllerAs: '$mention', link: function link($scope, $element, $attrs, _ref) { var _ref2 = _slicedToArray(_ref, 2); var ngModel = _ref2[0]; var uiMention = _ref2[1]; uiMention.init(ngModel); } }; }); 'use strict'; angular.module('ui.mention').controller('uiMention', ["$element", "$scope", "$attrs", "$q", "$timeout", "$document", function ($element, $scope, $attrs, $q, $timeout, $document) { var _this = this; // Beginning of input or preceeded by spaces: @sometext this.delimiter = '@'; // this.pattern is left for backward compatibility this.searchPattern = this.pattern || new RegExp("(?:\\s+|^)" + this.delimiter + "(\\w+(?: \\w+)?)$"); this.decodePattern = new RegExp(this.delimiter + "\[[\\s\\w]+:[0-9a-z-]+\]", "gi"); this.$element = $element; this.choices = []; this.mentions = []; var ngModel; /** * $mention.init() * * Initializes the plugin by setting up the ngModelController properties * * @param {ngModelController} model */ this.init = function (model) { // Leading whitespace shows up in the textarea but not the preview $attrs.ngTrim = 'false'; ngModel = model; ngModel.$parsers.push(function (value) { // Removes any mentions that aren't used _this.mentions = _this.mentions.filter(function (mention) { if (~value.indexOf(_this.label(mention))) { return value = value.split(_this.label(mention)).join(_this.encode(mention)); } }); _this.render(value); return value; }); ngModel.$formatters.push(function () { var value = arguments.length <= 0 || arguments[0] === undefined ? '' : arguments[0]; // In case the value is a different primitive value = value.toString(); // Removes any mentions that aren't used _this.mentions = _this.mentions.filter(function (mention) { if (~value.indexOf(_this.encode(mention))) { value = value.split(_this.encode(mention)).join(_this.label(mention)); return true; } else { return false; } }); return value; }); ngModel.$render = function () { $element.val(ngModel.$viewValue || ''); $timeout(_this.autogrow, true); _this.render(); }; }; var temp = document.createElement('span'); function parseContentAsText(content) { try { temp.textContent = content; return temp.innerHTML; } finally { temp.textContent = null; } } /** * $mention.render() * * Renders the syntax-encoded version to an HTML element for 'highlighting' effect * * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) * @return {string} HTML string */ this.render = function () { var html = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; html = (html || '').toString(); // Convert input to text, to prevent script injection/rich text html = parseContentAsText(html); _this.mentions.forEach(function (mention) { html = html.split(_this.encode(mention)).join(_this.highlight(mention)); }); _this.renderElement().html(html); return html; }; /** * $mention.renderElement() * * Get syntax-encoded HTML element * * @return {Element} HTML element */ this.renderElement = function () { return $element.next(); }; /** * $mention.highlight() * * Returns a choice in HTML highlight formatting * * @param {mixed|object} choice The choice to be highlighted * @return {string} HTML highlighted version of the choice */ this.highlight = function (choice) { return '<span>' + _this.label(choice) + '</span>'; }; /** * $mention.decode() * * @note NOT CURRENTLY USED * @param {string} [text] syntax encoded string (default: ngModel.$modelValue) * @return {string} plaintext string with encoded labels used */ this.decode = function () { var value = arguments.length <= 0 || arguments[0] === undefined ? ngModel.$modelValue : arguments[0]; return value ? value.replace(_this.decodePattern, '$1') : ''; }; /** * $mention.label() * * Converts a choice object to a human-readable string * * @param {mixed|object} choice The choice to be rendered * @return {string} Human-readable string version of choice */ this.label = function (choice) { return choice.first + ' ' + choice.last; }; /** * $mention.encode() * * Converts a choice object to a syntax-encoded string * * @param {mixed|object} choice The choice to be encoded * @return {string} Syntax-encoded string version of choice */ this.encode = function (choice) { return _this.delimiter + '[' + _this.label(choice) + ':' + choice.id + ']'; }; /** * $mention.replace() * * Replaces the trigger-text with the mention label * * @param {mixed|object} mention The choice to replace with * @param {regex.exec()} [search] A regex search result for the trigger-text (default: this.searching) * @param {string} [text] String to perform the replacement on (default: ngModel.$viewValue) * @return {string} Human-readable string */ this.replace = function (mention) { var search = arguments.length <= 1 || arguments[1] === undefined ? _this.searching : arguments[1]; var text = arguments.length <= 2 || arguments[2] === undefined ? ngModel.$viewValue : arguments[2]; // TODO: come up with a better way to detect what to remove // TODO: consider alternative to using regex match if (search === null) { return text; } text = text.substr(0, search.index + search[0].indexOf(_this.delimiter)) + _this.label(mention) + ' ' + text.substr(search.index + search[0].length); return text; }; /** * $mention.select() * * Adds a choice to this.mentions collection and updates the view * * @param {mixed|object} [choice] The selected choice (default: activeChoice) */ this.select = function () { var choice = arguments.length <= 0 || arguments[0] === undefined ? _this.activeChoice : arguments[0]; if (!choice) { return false; } var mentionExists = _this.mentions.some(function (mention) { return _this.encode(mention) === _this.encode(choice); }); // Add the mention, unless its already been mentioned if (!mentionExists) { _this.mentions.push(choice); } // Replace the search with the label ngModel.$setViewValue(_this.replace(choice)); // Close choices panel _this.cancel(); // Update the textarea ngModel.$render(); }; /** * $mention.up() * * Moves this.activeChoice up the this.choices collection */ this.up = function () { var index = _this.choices.indexOf(_this.activeChoice); if (index > 0) { _this.activeChoice = _this.choices[index - 1]; } else { _this.activeChoice = _this.choices[_this.choices.length - 1]; } }; /** * $mention.down() * * Moves this.activeChoice down the this.choices collection */ this.down = function () { var index = _this.choices.indexOf(_this.activeChoice); if (index < _this.choices.length - 1) { _this.activeChoice = _this.choices[index + 1]; } else { _this.activeChoice = _this.choices[0]; } }; /** * $mention.search() * * Searches for a list of mention choices and populates * $mention.choices and $mention.activeChoice * * @param {regex.exec()} match The trigger-text regex match object * @todo Try to avoid using a regex match object */ this.search = function (match) { _this.searching = match; return $q.when(_this.findChoices(match, _this.mentions)).then(function (choices) { _this.choices = choices; _this.activeChoice = choices[0]; return choices; }); }; /** * $mention.findChoices() * * @param {regex.exec()} match The trigger-text regex match object * @todo Try to avoid using a regex match object * @todo Make it easier to override this * @return {array[choice]|Promise} The list of possible choices */ this.findChoices = function (match, mentions) { return []; }; /** * $mention.cancel() * * Clears the choices dropdown info and stops searching */ this.cancel = function () { _this.choices = []; _this.searching = null; }; this.autogrow = function () { $element[0].style.height = 0; // autoshrink - need accurate scrollHeight var style = getComputedStyle($element[0]); if (style.boxSizing == 'border-box') { $element[0].style.height = $element[0].scrollHeight + 'px'; } }; // Interactions to trigger searching $element.on('keyup click focus', function (event) { // If event is fired AFTER activeChoice move is performed if (_this.moved) { return _this.moved = false; } // Don't trigger on selection if ($element[0].selectionStart != $element[0].selectionEnd) { return; } var text = $element.val(); // text to left of cursor ends with `@sometext` var match = _this.searchPattern.exec(text.substr(0, $element[0].selectionStart)); if (match) { _this.search(match); } else { _this.cancel(); } if (!$scope.$$phase) { $scope.$apply(); } }); $element.on('keydown', function (event) { if (!_this.searching) { return; } switch (event.keyCode) { case 13: // return _this.select(); break; case 38: // up _this.up(); break; case 40: // down _this.down(); break; default: // Exit function return; } _this.moved = true; event.preventDefault(); if (!$scope.$$phase) { $scope.$apply(); } }); this.onMouseup = (function (event) { var _this2 = this; if (event.target == $element[0]) { return; } $document.off('mouseup', this.onMouseup); if (!this.searching) { return; } // Let ngClick fire first $scope.$evalAsync(function () { _this2.cancel(); }); }).bind(this); $element.on('focus', function (event) { $document.on('mouseup', _this.onMouseup); }); // Autogrow is mandatory beacuse the textarea scrolls away from highlights $element.on('input', this.autogrow); // Initialize autogrow height $timeout(this.autogrow, true); }]);