angular-ui-mention
Version:
Facebook-like @mentions for text inputs built around composability
366 lines (317 loc) • 9.48 kB
JavaScript
angular.module('ui.mention')
.controller('uiMention', function (
$element, $scope, $attrs, $q, $timeout, $document
) {
// 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 = (model) => {
// Leading whitespace shows up in the textarea but not the preview
$attrs.ngTrim = 'false';
ngModel = model;
ngModel.$parsers.push(value => {
// Removes any mentions that aren't used
this.mentions = this.mentions.filter((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((value = '') => {
// In case the value is a different primitive
value = value.toString();
// Removes any mentions that aren't used
this.mentions = this.mentions.filter((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 = () => {
$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 = (html = ngModel.$modelValue) => {
html = (html || '').toString();
// Convert input to text, to prevent script injection/rich text
html = parseContentAsText(html);
this.mentions.forEach((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 = () => {
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 = (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 = (value = ngModel.$modelValue) => {
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 = (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 = (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 = (mention, search = this.searching, text = ngModel.$viewValue) => {
// 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 = (choice = this.activeChoice) => {
if (!choice) {
return false;
}
const mentionExists = this.mentions
.some(mention => 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 = () => {
let 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 = () => {
let 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 = (match) => {
this.searching = match;
return $q.when(this.findChoices(match, this.mentions))
.then((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 = (match, mentions) => {
return [];
};
/**
* $mention.cancel()
*
* Clears the choices dropdown info and stops searching
*/
this.cancel = () => {
this.choices = [];
this.searching = null;
};
this.autogrow = () => {
$element[0].style.height = 0; // autoshrink - need accurate scrollHeight
let 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', (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;
}
let text = $element.val();
// text to left of cursor ends with `@sometext`
let 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', (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) {
if (event.target == $element[0]) {
return
}
$document.off('mouseup', this.onMouseup);
if (!this.searching) {
return;
}
// Let ngClick fire first
$scope.$evalAsync(() => {
this.cancel();
});
}).bind(this);
$element.on('focus', (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);
});