Commit 88af141c authored by Randall Leeds's avatar Randall Leeds

Merge pull request #2042 from hypothesis/lookahead-tags-3

Autocomplete feature for tags
parents d6f88ff4 a87877b0
...@@ -126,6 +126,7 @@ require('./auth-service') ...@@ -126,6 +126,7 @@ require('./auth-service')
require('./cross-frame-service') require('./cross-frame-service')
require('./flash-service') require('./flash-service')
require('./permissions-service') require('./permissions-service')
require('./local-storage-service')
require('./store-service') require('./store-service')
require('./threading-service') require('./threading-service')
......
...@@ -29,12 +29,13 @@ validate = (value) -> ...@@ -29,12 +29,13 @@ validate = (value) ->
# {@link annotationMapper AnnotationMapper service} for persistence. # {@link annotationMapper AnnotationMapper service} for persistence.
### ###
AnnotationController = [ AnnotationController = [
'$scope', '$timeout', '$rootScope', '$document', '$scope', '$timeout', '$q', '$rootScope', '$document',
'auth', 'drafts', 'flash', 'permissions', 'auth', 'drafts', 'flash', 'permissions', 'tagHelpers',
'timeHelpers', 'annotationUI', 'annotationMapper' 'timeHelpers', 'annotationUI', 'annotationMapper'
($scope, $timeout, $rootScope, $document, ($scope, $timeout, $q, $rootScope, $document,
auth, drafts, flash, permissions, auth, drafts, flash, permissions, tagHelpers,
timeHelpers, annotationUI, annotationMapper) -> timeHelpers, annotationUI, annotationMapper) ->
@annotation = {} @annotation = {}
@action = 'view' @action = 'view'
@document = null @document = null
...@@ -50,6 +51,15 @@ AnnotationController = [ ...@@ -50,6 +51,15 @@ AnnotationController = [
original = null original = null
vm = this vm = this
###*
# @ngdoc method
# @name annotation.AnnotationController#tagsAutoComplete.
# @returns {Promise} immediately resolved to {string[]} -
# the tags to show in autocomplete.
###
this.tagsAutoComplete = (query) ->
$q.when(tagHelpers.filterTags(query))
###* ###*
# @ngdoc method # @ngdoc method
# @name annotation.AnnotationController#isComment. # @name annotation.AnnotationController#isComment.
...@@ -145,6 +155,11 @@ AnnotationController = [ ...@@ -145,6 +155,11 @@ AnnotationController = [
unless validate(@annotation) unless validate(@annotation)
return flash 'info', 'Please add text or a tag before publishing.' return flash 'info', 'Please add text or a tag before publishing.'
# Update stored tags with the new tags of this annotation
tags = @annotation.tags.filter (tag) ->
tag.text not in (model.tags or [])
tagHelpers.storeTags(tags)
angular.extend model, @annotation, angular.extend model, @annotation,
tags: (tag.text for tag in @annotation.tags) tags: (tag.text for tag in @annotation.tags)
......
privacy = ['$window', 'permissions', ($window, permissions) -> privacy = ['localstorage', 'permissions', (localstorage, permissions) ->
VISIBILITY_KEY ='hypothesis.visibility' VISIBILITY_KEY ='hypothesis.visibility'
VISIBILITY_PUBLIC = 'public' VISIBILITY_PUBLIC = 'public'
VISIBILITY_PRIVATE = 'private' VISIBILITY_PRIVATE = 'private'
...@@ -16,24 +16,6 @@ privacy = ['$window', 'permissions', ($window, permissions) -> ...@@ -16,24 +16,6 @@ privacy = ['$window', 'permissions', ($window, permissions) ->
isPublic = (level) -> level == VISIBILITY_PUBLIC isPublic = (level) -> level == VISIBILITY_PUBLIC
# Detection is needed because we run often as a third party widget and
# third party storage blocking often blocks cookies and local storage
# https://github.com/Modernizr/Modernizr/blob/master/feature-detects/storage/localstorage.js
storage = do ->
key = 'hypothesis.testKey'
try
$window.localStorage.setItem key, key
$window.localStorage.removeItem key
$window.localStorage
catch
memoryStorage = {}
getItem: (key) ->
if key of memoryStorage then memoryStorage[key] else null
setItem: (key, value) ->
memoryStorage[key] = value
removeItem: (key) ->
delete memoryStorage[key]
link: (scope, elem, attrs, controller) -> link: (scope, elem, attrs, controller) ->
return unless controller? return unless controller?
...@@ -62,7 +44,7 @@ privacy = ['$window', 'permissions', ($window, permissions) -> ...@@ -62,7 +44,7 @@ privacy = ['$window', 'permissions', ($window, permissions) ->
controller.$render = -> controller.$render = ->
unless controller.$modelValue.read?.length unless controller.$modelValue.read?.length
name = storage.getItem VISIBILITY_KEY name = localstorage.getItem VISIBILITY_KEY
name ?= VISIBILITY_PUBLIC name ?= VISIBILITY_PUBLIC
level = getLevel(name) level = getLevel(name)
controller.$setViewValue level controller.$setViewValue level
...@@ -71,7 +53,7 @@ privacy = ['$window', 'permissions', ($window, permissions) -> ...@@ -71,7 +53,7 @@ privacy = ['$window', 'permissions', ($window, permissions) ->
scope.levels = levels scope.levels = levels
scope.setLevel = (level) -> scope.setLevel = (level) ->
storage.setItem VISIBILITY_KEY, level.name localstorage.setItem VISIBILITY_KEY, level.name
controller.$setViewValue level controller.$setViewValue level
controller.$render() controller.$render()
scope.isPublic = isPublic scope.isPublic = isPublic
......
...@@ -19,6 +19,7 @@ describe 'h.directives.annotation', -> ...@@ -19,6 +19,7 @@ describe 'h.directives.annotation', ->
fakePermissions = null fakePermissions = null
fakePersonaFilter = null fakePersonaFilter = null
fakeStore = null fakeStore = null
fakeTagHelpers = null
fakeTimeHelpers = null fakeTimeHelpers = null
fakeUrlEncodeFilter = null fakeUrlEncodeFilter = null
sandbox = null sandbox = null
...@@ -48,6 +49,7 @@ describe 'h.directives.annotation', -> ...@@ -48,6 +49,7 @@ describe 'h.directives.annotation', ->
remove: sandbox.stub() remove: sandbox.stub()
} }
fakeFlash = sandbox.stub() fakeFlash = sandbox.stub()
fakeMomentFilter = sandbox.stub().returns('ages ago') fakeMomentFilter = sandbox.stub().returns('ages ago')
fakePermissions = { fakePermissions = {
isPublic: sandbox.stub().returns(true) isPublic: sandbox.stub().returns(true)
...@@ -57,6 +59,10 @@ describe 'h.directives.annotation', -> ...@@ -57,6 +59,10 @@ describe 'h.directives.annotation', ->
private: sandbox.stub().returns({read: ['justme']}) private: sandbox.stub().returns({read: ['justme']})
} }
fakePersonaFilter = sandbox.stub().returnsArg(0) fakePersonaFilter = sandbox.stub().returnsArg(0)
fakeTagsHelpers = {
filterTags: sandbox.stub().returns('a while ago')
refreshTags: sandbox.stub().returns(30)
}
fakeTimeHelpers = { fakeTimeHelpers = {
toFuzzyString: sandbox.stub().returns('a while ago') toFuzzyString: sandbox.stub().returns('a while ago')
nextFuzzyUpdate: sandbox.stub().returns(30) nextFuzzyUpdate: sandbox.stub().returns(30)
...@@ -72,6 +78,7 @@ describe 'h.directives.annotation', -> ...@@ -72,6 +78,7 @@ describe 'h.directives.annotation', ->
$provide.value 'permissions', fakePermissions $provide.value 'permissions', fakePermissions
$provide.value 'personaFilter', fakePersonaFilter $provide.value 'personaFilter', fakePersonaFilter
$provide.value 'store', fakeStore $provide.value 'store', fakeStore
$provide.value 'tagHelpers', fakeTagHelpers
$provide.value 'timeHelpers', fakeTimeHelpers $provide.value 'timeHelpers', fakeTimeHelpers
$provide.value 'urlencodeFilter', fakeUrlEncodeFilter $provide.value 'urlencodeFilter', fakeUrlEncodeFilter
return return
......
...@@ -12,6 +12,7 @@ describe 'h.directives.privacy', -> ...@@ -12,6 +12,7 @@ describe 'h.directives.privacy', ->
$window = null $window = null
fakeAuth = null fakeAuth = null
fakePermissions = null fakePermissions = null
fakeLocalStorage = null
sandbox = null sandbox = null
before -> before ->
...@@ -28,6 +29,13 @@ describe 'h.directives.privacy', -> ...@@ -28,6 +29,13 @@ describe 'h.directives.privacy', ->
user: 'acct:angry.joe@texas.com' user: 'acct:angry.joe@texas.com'
} }
storage = {}
fakeLocalStorage = {
getItem: sandbox.spy (key) -> storage[key]
setItem: sandbox.spy (key, value) -> storage[key] = value
removeItem: sandbox.spy (key) -> delete storage[key]
}
fakePermissions = { fakePermissions = {
isPublic: sandbox.stub().returns(true) isPublic: sandbox.stub().returns(true)
isPrivate: sandbox.stub().returns(false) isPrivate: sandbox.stub().returns(false)
...@@ -37,6 +45,7 @@ describe 'h.directives.privacy', -> ...@@ -37,6 +45,7 @@ describe 'h.directives.privacy', ->
} }
$provide.value 'auth', fakeAuth $provide.value 'auth', fakeAuth
$provide.value 'localstorage', fakeLocalStorage
$provide.value 'permissions', fakePermissions $provide.value 'permissions', fakePermissions
return return
...@@ -48,31 +57,7 @@ describe 'h.directives.privacy', -> ...@@ -48,31 +57,7 @@ describe 'h.directives.privacy', ->
afterEach -> afterEach ->
sandbox.restore() sandbox.restore()
describe 'memory fallback', -> describe 'saves visibility level', ->
$scope2 = null
beforeEach inject (_$rootScope_) ->
$scope2 = _$rootScope_.$new()
$window.localStorage = null
it 'stores the default visibility level when it changes', ->
$scope.permissions = {read: ['acct:user@example.com']}
$element = $compile('<privacy ng-model="permissions">')($scope)
$scope.$digest()
$isolateScope = $element.isolateScope()
$isolateScope.setLevel(name: VISIBILITY_PUBLIC)
$scope2.permissions = {read: []}
$element = $compile('<privacy ng-model="permissions">')($scope2)
$scope2.$digest()
# Roundabout way: the storage works because the directive
# could read out the privacy level
readPermissions = $scope2.permissions.read[0]
assert.equal readPermissions, 'everybody'
describe 'has localStorage', ->
it 'stores the default visibility level when it changes', -> it 'stores the default visibility level when it changes', ->
$scope.permissions = {read: ['acct:user@example.com']} $scope.permissions = {read: ['acct:user@example.com']}
...@@ -82,19 +67,15 @@ describe 'h.directives.privacy', -> ...@@ -82,19 +67,15 @@ describe 'h.directives.privacy', ->
$isolateScope.setLevel(name: VISIBILITY_PUBLIC) $isolateScope.setLevel(name: VISIBILITY_PUBLIC)
expected = VISIBILITY_PUBLIC expected = VISIBILITY_PUBLIC
stored = $window.localStorage.getItem VISIBILITY_KEY stored = fakeLocalStorage.getItem VISIBILITY_KEY
assert.equal stored, expected assert.equal stored, expected
describe 'setting permissions', -> describe 'setting permissions', ->
$element = null $element = null
store = null
beforeEach ->
store = $window.localStorage
describe 'when no setting is stored', -> describe 'when no setting is stored', ->
beforeEach -> beforeEach ->
store.removeItem VISIBILITY_KEY fakeLocalStorage.removeItem VISIBILITY_KEY
it 'defaults to public', -> it 'defaults to public', ->
$scope.permissions = {read: []} $scope.permissions = {read: []}
...@@ -105,7 +86,7 @@ describe 'h.directives.privacy', -> ...@@ -105,7 +86,7 @@ describe 'h.directives.privacy', ->
describe 'when permissions.read is empty', -> describe 'when permissions.read is empty', ->
beforeEach -> beforeEach ->
store.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC
$scope.permissions = {read: []} $scope.permissions = {read: []}
$element = $compile('<privacy ng-model="permissions">')($scope) $element = $compile('<privacy ng-model="permissions">')($scope)
...@@ -115,14 +96,14 @@ describe 'h.directives.privacy', -> ...@@ -115,14 +96,14 @@ describe 'h.directives.privacy', ->
assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC
it 'does not alter the level on subsequent renderings', -> it 'does not alter the level on subsequent renderings', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$scope.permissions.read = ['acct:user@example.com'] $scope.permissions.read = ['acct:user@example.com']
$scope.$digest() $scope.$digest()
assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC assert.equal $element.isolateScope().level.name, VISIBILITY_PUBLIC
describe 'when permissions.read is filled', -> describe 'when permissions.read is filled', ->
it 'does not alter the level', -> it 'does not alter the level', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$scope.permissions = {read: ['group:__world__']} $scope.permissions = {read: ['group:__world__']}
$element = $compile('<privacy ng-model="permissions">')($scope) $element = $compile('<privacy ng-model="permissions">')($scope)
...@@ -135,14 +116,14 @@ describe 'h.directives.privacy', -> ...@@ -135,14 +116,14 @@ describe 'h.directives.privacy', ->
$scope.permissions = {read: []} $scope.permissions = {read: []}
it 'fills the permissions fields with the auth.user name', -> it 'fills the permissions fields with the auth.user name', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PRIVATE
$element = $compile('<privacy ng-model="permissions">')($scope) $element = $compile('<privacy ng-model="permissions">')($scope)
$scope.$digest() $scope.$digest()
assert.deepEqual $scope.permissions, fakePermissions.private() assert.deepEqual $scope.permissions, fakePermissions.private()
it 'puts group_world into the read permissions for public visibility', -> it 'puts group_world into the read permissions for public visibility', ->
store.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC fakeLocalStorage.setItem VISIBILITY_KEY, VISIBILITY_PUBLIC
$element = $compile('<privacy ng-model="permissions">')($scope) $element = $compile('<privacy ng-model="permissions">')($scope)
$scope.$digest() $scope.$digest()
......
...@@ -4,6 +4,7 @@ angular.module('h.helpers', ['bootstrap']) ...@@ -4,6 +4,7 @@ angular.module('h.helpers', ['bootstrap'])
require('./form-helpers') require('./form-helpers')
require('./string-helpers') require('./string-helpers')
require('./tag-helpers')
require('./time-helpers') require('./time-helpers')
require('./ui-helpers') require('./ui-helpers')
require('./xsrf-service') require('./xsrf-service')
createTagHelpers = ['localstorage', (localstorage) ->
TAGS_LIST_KEY = 'hypothesis.user.tags.list'
TAGS_MAP_KEY = 'hypothesis.user.tags.map'
filterTags: (query) ->
savedTags = localstorage.getObject TAGS_LIST_KEY
savedTags ?= []
# Only show tags having query as a substring
filterFn = (e) ->
e.toLowerCase().indexOf(query.toLowerCase()) > -1
savedTags.filter(filterFn)
# Add newly added tags from an annotation to the stored ones and refresh
# timestamp for every tags used.
storeTags: (tags) ->
savedTags = localstorage.getObject TAGS_MAP_KEY
savedTags ?= {}
for tag in tags
if savedTags[tag.text]?
# Update counter and timestamp
savedTags[tag.text].count += 1
savedTags[tag.text].updated = Date.now()
else
# Brand new tag, create an entry for it
savedTags[tag.text] = {
text: tag.text
count: 1
updated: Date.now()
}
localstorage.setObject TAGS_MAP_KEY, savedTags
tagsList = []
for tag of savedTags
tagsList[tagsList.length] = tag
# Now produce TAGS_LIST, ordered by (count desc, lexical asc)
compareFn = (t1, t2) ->
if savedTags[t1].count != savedTags[t2].count
return savedTags[t2].count - savedTags[t1].count
else
return -1 if t1 < t2
return 1 if t1 > t2
return 0
tagsList = tagsList.sort(compareFn)
localstorage.setObject TAGS_LIST_KEY, tagsList
]
angular.module('h.helpers')
.service('tagHelpers', createTagHelpers)
{module, inject} = require('angular-mock')
assert = chai.assert
describe 'h.helpers:tag-helpers', ->
TAGS_LIST_KEY = 'hypothesis.user.tags.list'
TAGS_MAP_KEY = 'hypothesis.user.tags.map'
fakeLocalStorage = null
sandbox = null
savedTagsMap = null
savedTagsList = null
tagHelpers = null
before ->
angular.module('h.helpers', [])
require('../tag-helpers')
beforeEach module('h.helpers')
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeStorage = {}
fakeLocalStorage = {
getObject: sandbox.spy (key) -> fakeStorage[key]
setObject: sandbox.spy (key, value) -> fakeStorage[key] = value
wipe: -> fakeStorage = {}
}
$provide.value 'localstorage', fakeLocalStorage
return
beforeEach inject (_tagHelpers_) ->
tagHelpers = _tagHelpers_
afterEach ->
sandbox.restore()
beforeEach ->
fakeLocalStorage.wipe()
stamp = Date.now()
savedTagsMap =
foo:
text: 'foo'
count: 1
updated: stamp
bar:
text: 'bar'
count: 5
updated: stamp
future:
text: 'future'
count: 2
updated: stamp
argon:
text: 'argon'
count: 1
updated: stamp
savedTagsList = ['bar', 'future', 'argon', 'foo']
fakeLocalStorage.setObject TAGS_MAP_KEY, savedTagsMap
fakeLocalStorage.setObject TAGS_LIST_KEY, savedTagsList
describe 'filterTags()', ->
it 'returns tags having the query as a substring', ->
tags = tagHelpers.filterTags('a')
assert.deepEqual(tags, ['bar', 'argon'])
it 'is case insensitive', ->
tags = tagHelpers.filterTags('Ar')
assert.deepEqual(tags, ['bar', 'argon'])
describe 'storeTags()', ->
it 'saves new tags to storage', ->
tags = [{text: 'new'}]
tagHelpers.storeTags(tags)
storedTagsList = fakeLocalStorage.getObject TAGS_LIST_KEY
assert.deepEqual(storedTagsList, ['bar', 'future', 'argon', 'foo', 'new'])
storedTagsMap = fakeLocalStorage.getObject TAGS_MAP_KEY
assert.isTrue(storedTagsMap.new?)
assert.equal(storedTagsMap.new.count, 1)
assert.equal(storedTagsMap.new.text, 'new')
it 'increases the count for a tag already stored', ->
tags = [{text: 'bar'}]
tagHelpers.storeTags(tags)
storedTagsMap = fakeLocalStorage.getObject TAGS_MAP_KEY
assert.equal(storedTagsMap.bar.count, 6)
it 'list is ordered by count desc, lexical asc', ->
# Will increase from 1 to 6 (as future)
tags = [{text: 'foo'}]
tagHelpers.storeTags(tags)
tagHelpers.storeTags(tags)
tagHelpers.storeTags(tags)
tagHelpers.storeTags(tags)
tagHelpers.storeTags(tags)
storedTagsList = fakeLocalStorage.getObject TAGS_LIST_KEY
assert.deepEqual(storedTagsList, ['foo', 'bar', 'future', 'argon'])
it 'gets/sets its objects from the localstore', ->
tags = [{text: 'foo'}]
tagHelpers.storeTags(tags)
assert.called(fakeLocalStorage.getObject)
assert.called(fakeLocalStorage.setObject)
localstorage = ['$window', ($window) ->
# Detection is needed because we run often as a third party widget and
# third party storage blocking often blocks cookies and local storage
# https://github.com/Modernizr/Modernizr/blob/master/feature-detects/storage/localstorage.js
storage = do ->
key = 'hypothesis.testKey'
try
$window.localStorage.setItem key, key
$window.localStorage.removeItem key
$window.localStorage
catch
memoryStorage = {}
getItem: (key) ->
if key of memoryStorage then memoryStorage[key] else null
setItem: (key, value) ->
memoryStorage[key] = value
removeItem: (key) ->
delete memoryStorage[key]
return {
getItem: (key) ->
storage.getItem key
getObject: (key) ->
json = storage.getItem key
return JSON.parse json if json
null
setItem: (key, value) ->
storage.setItem key, value
setObject: (key, value) ->
repr = JSON.stringify value
storage.setItem key, repr
removeItem: (key) ->
storage.removeItem key
}
]
angular.module('h')
.service('localstorage', localstorage)
{module, inject} = require('angular-mock')
assert = chai.assert
sinon.assert.expose assert, prefix: null
describe 'h:localstorage', ->
fakeWindow = null
sandbox = null
before ->
angular.module('h', [])
require('../local-storage-service')
beforeEach module('h')
describe 'memory fallback', ->
localstorage = null
key = null
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeWindow = {
localStorage: {}
}
$provide.value '$window', fakeWindow
return
afterEach ->
sandbox.restore()
beforeEach inject (_localstorage_) ->
localstorage = _localstorage_
key = 'test.memory.key'
it 'sets/gets Item', ->
value = 'What shall we do with a drunken sailor?'
localstorage.setItem key, value
actual = localstorage.getItem key
assert.equal value, actual
it 'removes item', ->
localstorage.setItem key, ''
localstorage.removeItem key
result = localstorage.getItem key
assert.isNull result
it 'sets/gets Object', ->
data = {'foo': 'bar'}
localstorage.setObject key, data
stringified = localstorage.getItem key
assert.equal stringified, JSON.stringify data
actual = localstorage.getObject key
assert.deepEqual actual, data
describe 'browser localStorage', ->
localstorage = null
beforeEach module ($provide) ->
sandbox = sinon.sandbox.create()
fakeWindow = {
localStorage: {
getItem: sandbox.stub()
setItem: sandbox.stub()
removeItem: sandbox.stub()
}
}
$provide.value '$window', fakeWindow
return
afterEach ->
sandbox.restore()
beforeEach inject (_localstorage_) ->
localstorage = _localstorage_
it 'uses window.localStorage functions to handle data', ->
key = 'test.storage.key'
data = 'test data'
localstorage.setItem key, data
assert.calledWith fakeWindow.localStorage.setItem, key, data
/*! /*!
* ngTagsInput v2.0.1 * ngTagsInput v2.2.0
* http://mbenford.github.io/ngTagsInput * http://mbenford.github.io/ngTagsInput
* *
* Copyright (c) 2013-2014 Michael Benford * Copyright (c) 2013-2015 Michael Benford
* License: MIT * License: MIT
* *
* Generated at 2014-04-13 21:25:38 -0300 * Generated at 2015-03-02 01:54:50 -0300
*/ */
(function() { (function() {
'use strict'; 'use strict';
...@@ -21,55 +21,8 @@ var KEYS = { ...@@ -21,55 +21,8 @@ var KEYS = {
comma: 188 comma: 188
}; };
function SimplePubSub() { var MAX_SAFE_INTEGER = 9007199254740991;
var events = {}; var SUPPORTED_INPUT_TYPES = ['text', 'email', 'url'];
return {
on: function(names, handler) {
names.split(' ').forEach(function(name) {
if (!events[name]) {
events[name] = [];
}
events[name].push(handler);
});
return this;
},
trigger: function(name, args) {
angular.forEach(events[name], function(handler) {
handler.call(null, args);
});
return this;
}
};
}
function makeObjectArray(array, key) {
array = array || [];
if (array.length > 0 && !angular.isObject(array[0])) {
array.forEach(function(item, index) {
array[index] = {};
array[index][key] = item;
});
}
return array;
}
function findInObjectArray(array, obj, key) {
var item = null;
for (var i = 0; i < array.length; i++) {
// I'm aware of the internationalization issues regarding toLowerCase()
// but I couldn't come up with a better solution right now
if (array[i][key].toLowerCase() === obj[key].toLowerCase()) {
item = array[i];
break;
}
}
return item;
}
function replaceAll(str, substr, newSubstr) {
var expression = substr.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
return str.replace(new RegExp(expression, 'gi'), newSubstr);
}
var tagsInput = angular.module('ngTagsInput', []); var tagsInput = angular.module('ngTagsInput', []);
...@@ -83,12 +36,13 @@ var tagsInput = angular.module('ngTagsInput', []); ...@@ -83,12 +36,13 @@ var tagsInput = angular.module('ngTagsInput', []);
* *
* @param {string} ngModel Assignable angular expression to data-bind to. * @param {string} ngModel Assignable angular expression to data-bind to.
* @param {string=} [displayProperty=text] Property to be rendered as the tag label. * @param {string=} [displayProperty=text] Property to be rendered as the tag label.
* @param {string=} [type=text] Type of the input element. Only 'text', 'email' and 'url' are supported values.
* @param {number=} tabindex Tab order of the control. * @param {number=} tabindex Tab order of the control.
* @param {string=} [placeholder=Add a tag] Placeholder text for the control. * @param {string=} [placeholder=Add a tag] Placeholder text for the control.
* @param {number=} [minLength=3] Minimum length for a new tag. * @param {number=} [minLength=3] Minimum length for a new tag.
* @param {number=} maxLength Maximum length allowed for a new tag. * @param {number=} [maxLength=MAX_SAFE_INTEGER] Maximum length allowed for a new tag.
* @param {number=} minTags Sets minTags validation error key if the number of tags added is less than minTags. * @param {number=} [minTags=0] Sets minTags validation error key if the number of tags added is less than minTags.
* @param {number=} maxTags Sets maxTags validation error key if the number of tags added is greater than maxTags. * @param {number=} [maxTags=MAX_SAFE_INTEGER] Sets maxTags validation error key if the number of tags added is greater than maxTags.
* @param {boolean=} [allowLeftoverText=false] Sets leftoverText validation error key if there is any leftover text in * @param {boolean=} [allowLeftoverText=false] Sets leftoverText validation error key if there is any leftover text in
* the input element when the directive loses focus. * the input element when the directive loses focus.
* @param {string=} [removeTagSymbol=×] Symbol character for the remove tag button. * @param {string=} [removeTagSymbol=×] Symbol character for the remove tag button.
...@@ -96,6 +50,8 @@ var tagsInput = angular.module('ngTagsInput', []); ...@@ -96,6 +50,8 @@ var tagsInput = angular.module('ngTagsInput', []);
* @param {boolean=} [addOnSpace=false] Flag indicating that a new tag will be added on pressing the SPACE key. * @param {boolean=} [addOnSpace=false] Flag indicating that a new tag will be added on pressing the SPACE key.
* @param {boolean=} [addOnComma=true] Flag indicating that a new tag will be added on pressing the COMMA key. * @param {boolean=} [addOnComma=true] Flag indicating that a new tag will be added on pressing the COMMA key.
* @param {boolean=} [addOnBlur=true] Flag indicating that a new tag will be added when the input field loses focus. * @param {boolean=} [addOnBlur=true] Flag indicating that a new tag will be added when the input field loses focus.
* @param {boolean=} [addOnPaste=false] Flag indicating that the text pasted into the input field will be split into tags.
* @param {string=} [pasteSplitPattern=,] Regular expression used to split the pasted text into tags.
* @param {boolean=} [replaceSpacesWithDashes=true] Flag indicating that spaces will be replaced with dashes. * @param {boolean=} [replaceSpacesWithDashes=true] Flag indicating that spaces will be replaced with dashes.
* @param {string=} [allowedTagsPattern=.+] Regular expression that determines whether a new tag is valid. * @param {string=} [allowedTagsPattern=.+] Regular expression that determines whether a new tag is valid.
* @param {boolean=} [enableEditingLastTag=false] Flag indicating that the last tag will be moved back into * @param {boolean=} [enableEditingLastTag=false] Flag indicating that the last tag will be moved back into
...@@ -104,15 +60,17 @@ var tagsInput = angular.module('ngTagsInput', []); ...@@ -104,15 +60,17 @@ var tagsInput = angular.module('ngTagsInput', []);
* @param {boolean=} [addFromAutocompleteOnly=false] Flag indicating that only tags coming from the autocomplete list will be allowed. * @param {boolean=} [addFromAutocompleteOnly=false] Flag indicating that only tags coming from the autocomplete list will be allowed.
* When this flag is true, addOnEnter, addOnComma, addOnSpace, addOnBlur and * When this flag is true, addOnEnter, addOnComma, addOnSpace, addOnBlur and
* allowLeftoverText values are ignored. * allowLeftoverText values are ignored.
* @param {boolean=} [spellcheck=true] Flag indicating whether the browser's spellcheck is enabled for the input field or not.
* @param {expression} onTagAdded Expression to evaluate upon adding a new tag. The new tag is available as $tag. * @param {expression} onTagAdded Expression to evaluate upon adding a new tag. The new tag is available as $tag.
* @param {expression} onInvalidTag Expression to evaluate when a tag is invalid. The invalid tag is available as $tag.
* @param {expression} onTagRemoved Expression to evaluate upon removing an existing tag. The removed tag is available as $tag. * @param {expression} onTagRemoved Expression to evaluate upon removing an existing tag. The removed tag is available as $tag.
*/ */
tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", function($timeout, $document, tagsInputConfig) { tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig","tiUtil", function($timeout, $document, tagsInputConfig, tiUtil) {
function TagList(options, events) { function TagList(options, events) {
var self = {}, getTagText, setTagText, tagIsValid; var self = {}, getTagText, setTagText, tagIsValid;
getTagText = function(tag) { getTagText = function(tag) {
return tag[options.displayProperty]; return tiUtil.safeToString(tag[options.displayProperty]);
}; };
setTagText = function(tag, text) { setTagText = function(tag, text) {
...@@ -122,10 +80,11 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -122,10 +80,11 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
tagIsValid = function(tag) { tagIsValid = function(tag) {
var tagText = getTagText(tag); var tagText = getTagText(tag);
return tagText.length >= options.minLength && return tagText &&
tagText.length <= (options.maxLength || tagText.length) && tagText.length >= options.minLength &&
tagText.length <= options.maxLength &&
options.allowedTagsPattern.test(tagText) && options.allowedTagsPattern.test(tagText) &&
!findInObjectArray(self.items, tag, options.displayProperty); !tiUtil.findInObjectArray(self.items, tag, options.displayProperty);
}; };
self.items = []; self.items = [];
...@@ -137,7 +96,7 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -137,7 +96,7 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
}; };
self.add = function(tag) { self.add = function(tag) {
var tagText = getTagText(tag).trim(); var tagText = getTagText(tag);
if (options.replaceSpacesWithDashes) { if (options.replaceSpacesWithDashes) {
tagText = tagText.replace(/\s/g, '-'); tagText = tagText.replace(/\s/g, '-');
...@@ -149,7 +108,7 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -149,7 +108,7 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
self.items.push(tag); self.items.push(tag);
events.trigger('tag-added', { $tag: tag }); events.trigger('tag-added', { $tag: tag });
} }
else { else if (tagText) {
events.trigger('invalid-tag', { $tag: tag }); events.trigger('invalid-tag', { $tag: tag });
} }
...@@ -179,46 +138,53 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -179,46 +138,53 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
return self; return self;
} }
function validateType(type) {
return SUPPORTED_INPUT_TYPES.indexOf(type) !== -1;
}
return { return {
restrict: 'E', restrict: 'E',
require: 'ngModel', require: 'ngModel',
scope: { scope: {
tags: '=ngModel', tags: '=ngModel',
onTagAdded: '&', onTagAdded: '&',
onInvalidTag: '&',
onTagRemoved: '&' onTagRemoved: '&'
}, },
replace: false, replace: false,
transclude: true, transclude: true,
templateUrl: 'ngTagsInput/tags-input.html', templateUrl: 'ngTagsInput/tags-input.html',
controller: ["$scope","$attrs","$element", function($scope, $attrs, $element) { controller: ["$scope","$attrs","$element", function($scope, $attrs, $element) {
$scope.events = tiUtil.simplePubSub();
tagsInputConfig.load('tagsInput', $scope, $attrs, { tagsInputConfig.load('tagsInput', $scope, $attrs, {
type: [String, 'text', validateType],
placeholder: [String, 'Add a tag'], placeholder: [String, 'Add a tag'],
tabindex: [Number], tabindex: [Number, null],
removeTagSymbol: [String, String.fromCharCode(215)], removeTagSymbol: [String, String.fromCharCode(215)],
replaceSpacesWithDashes: [Boolean, true], replaceSpacesWithDashes: [Boolean, true],
minLength: [Number, 3], minLength: [Number, 3],
maxLength: [Number], maxLength: [Number, MAX_SAFE_INTEGER],
addOnEnter: [Boolean, true], addOnEnter: [Boolean, true],
addOnSpace: [Boolean, false], addOnSpace: [Boolean, false],
addOnComma: [Boolean, true], addOnComma: [Boolean, true],
addOnBlur: [Boolean, true], addOnBlur: [Boolean, true],
addOnPaste: [Boolean, false],
pasteSplitPattern: [RegExp, /,/],
allowedTagsPattern: [RegExp, /.+/], allowedTagsPattern: [RegExp, /.+/],
enableEditingLastTag: [Boolean, false], enableEditingLastTag: [Boolean, false],
minTags: [Number], minTags: [Number, 0],
maxTags: [Number], maxTags: [Number, MAX_SAFE_INTEGER],
displayProperty: [String, 'text'], displayProperty: [String, 'text'],
allowLeftoverText: [Boolean, false], allowLeftoverText: [Boolean, false],
addFromAutocompleteOnly: [Boolean, false] addFromAutocompleteOnly: [Boolean, false],
spellcheck: [Boolean, true]
}); });
$scope.events = new SimplePubSub();
$scope.tagList = new TagList($scope.options, $scope.events); $scope.tagList = new TagList($scope.options, $scope.events);
this.registerAutocomplete = function() { this.registerAutocomplete = function() {
var input = $element.find('input'); var input = $element.find('input');
input.on('keydown', function(e) {
$scope.events.trigger('input-keydown', e);
});
return { return {
addTag: function(tag) { addTag: function(tag) {
...@@ -230,6 +196,9 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -230,6 +196,9 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
getTags: function() { getTags: function() {
return $scope.tags; return $scope.tags;
}, },
getCurrentTagText: function() {
return $scope.newTag.text;
},
getOptions: function() { getOptions: function() {
return $scope.options; return $scope.options;
}, },
...@@ -245,72 +214,119 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -245,72 +214,119 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
tagList = scope.tagList, tagList = scope.tagList,
events = scope.events, events = scope.events,
options = scope.options, options = scope.options,
input = element.find('input'); input = element.find('input'),
validationOptions = ['minTags', 'maxTags', 'allowLeftoverText'],
setElementValidity;
setElementValidity = function() {
ngModelCtrl.$setValidity('maxTags', scope.tags.length <= options.maxTags);
ngModelCtrl.$setValidity('minTags', scope.tags.length >= options.minTags);
ngModelCtrl.$setValidity('leftoverText', options.allowLeftoverText ? true : !scope.newTag.text);
};
scope.newTag = {
text: '',
invalid: null,
setText: function(value) {
this.text = value;
events.trigger('input-change', value);
}
};
scope.getDisplayText = function(tag) {
return tiUtil.safeToString(tag[options.displayProperty]);
};
scope.track = function(tag) {
return tag[options.displayProperty];
};
scope.$watch('tags', function(value) {
scope.tags = tiUtil.makeObjectArray(value, options.displayProperty);
tagList.items = scope.tags;
});
scope.$watch('tags.length', function() {
setElementValidity();
});
scope.eventHandlers = {
input: {
change: function(text) {
events.trigger('input-change', text);
},
keydown: function($event) {
events.trigger('input-keydown', $event);
},
focus: function() {
if (scope.hasFocus) {
return;
}
scope.hasFocus = true;
events.trigger('input-focus');
},
blur: function() {
$timeout(function() {
var activeElement = $document.prop('activeElement'),
lostFocusToBrowserWindow = activeElement === input[0],
lostFocusToChildElement = element[0].contains(activeElement);
if (lostFocusToBrowserWindow || !lostFocusToChildElement) {
scope.hasFocus = false;
events.trigger('input-blur');
}
});
},
paste: function($event) {
events.trigger('input-paste', $event);
}
},
host: {
click: function() {
input[0].focus();
}
}
};
events events
.on('tag-added', scope.onTagAdded) .on('tag-added', scope.onTagAdded)
.on('invalid-tag', scope.onInvalidTag)
.on('tag-removed', scope.onTagRemoved) .on('tag-removed', scope.onTagRemoved)
.on('tag-added', function() { .on('tag-added', function() {
scope.newTag.text = ''; scope.newTag.setText('');
}) })
.on('tag-added tag-removed', function() { .on('tag-added tag-removed', function() {
// Sets the element to its dirty state
// In Angular 1.3 this will be replaced with $setDirty.
ngModelCtrl.$setViewValue(scope.tags); ngModelCtrl.$setViewValue(scope.tags);
}) })
.on('invalid-tag', function() { .on('invalid-tag', function() {
scope.newTag.invalid = true; scope.newTag.invalid = true;
}) })
.on('option-change', function(e) {
if (validationOptions.indexOf(e.name) !== -1) {
setElementValidity();
}
})
.on('input-change', function() { .on('input-change', function() {
tagList.selected = null; tagList.selected = null;
scope.newTag.invalid = null; scope.newTag.invalid = null;
}) })
.on('input-focus', function() { .on('input-focus', function() {
element.triggerHandler('focus');
ngModelCtrl.$setValidity('leftoverText', true); ngModelCtrl.$setValidity('leftoverText', true);
}) })
.on('input-blur', function() { .on('input-blur', function() {
if (!options.addFromAutocompleteOnly) { if (options.addOnBlur && !options.addFromAutocompleteOnly) {
if (options.addOnBlur) { tagList.addText(scope.newTag.text);
tagList.addText(scope.newTag.text);
}
ngModelCtrl.$setValidity('leftoverText', options.allowLeftoverText ? true : !scope.newTag.text);
}
});
scope.newTag = { text: '', invalid: null };
scope.getDisplayText = function(tag) {
return tag[options.displayProperty].trim();
};
scope.track = function(tag) {
return tag[options.displayProperty];
};
scope.newTagChange = function() {
events.trigger('input-change', scope.newTag.text);
};
scope.$watch('tags', function(value) {
scope.tags = makeObjectArray(value, options.displayProperty);
tagList.items = scope.tags;
});
scope.$watch('tags.length', function(value) {
ngModelCtrl.$setValidity('maxTags', angular.isUndefined(options.maxTags) || value <= options.maxTags);
ngModelCtrl.$setValidity('minTags', angular.isUndefined(options.minTags) || value >= options.minTags);
});
input
.on('keydown', function(e) {
// This hack is needed because jqLite doesn't implement stopImmediatePropagation properly.
// I've sent a PR to Angular addressing this issue and hopefully it'll be fixed soon.
// https://github.com/angular/angular.js/pull/4833
if (e.isImmediatePropagationStopped && e.isImmediatePropagationStopped()) {
return;
} }
element.triggerHandler('blur');
var key = e.keyCode, setElementValidity();
isModifier = e.shiftKey || e.altKey || e.ctrlKey || e.metaKey, })
.on('input-keydown', function(event) {
var key = event.keyCode,
isModifier = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey,
addKeys = {}, addKeys = {},
shouldAdd, shouldRemove; shouldAdd, shouldRemove;
...@@ -327,49 +343,34 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -327,49 +343,34 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
if (shouldAdd) { if (shouldAdd) {
tagList.addText(scope.newTag.text); tagList.addText(scope.newTag.text);
event.preventDefault();
scope.$apply();
e.preventDefault();
} }
else if (shouldRemove) { else if (shouldRemove) {
var tag = tagList.removeLast(); var tag = tagList.removeLast();
if (tag && options.enableEditingLastTag) { if (tag && options.enableEditingLastTag) {
scope.newTag.text = tag[options.displayProperty]; scope.newTag.setText(tag[options.displayProperty]);
} }
scope.$apply(); event.preventDefault();
e.preventDefault();
}
})
.on('focus', function() {
if (scope.hasFocus) {
return;
} }
scope.hasFocus = true;
events.trigger('input-focus');
scope.$apply();
}) })
.on('blur', function() { .on('input-paste', function(event) {
$timeout(function() { if (options.addOnPaste) {
var activeElement = $document.prop('activeElement'), var data = event.clipboardData.getData('text/plain');
lostFocusToBrowserWindow = activeElement === input[0], var tags = data.split(options.pasteSplitPattern);
lostFocusToChildElement = element[0].contains(activeElement); if (tags.length > 1) {
tags.forEach(function(tag) {
if (lostFocusToBrowserWindow || !lostFocusToChildElement) { tagList.addText(tag);
scope.hasFocus = false; });
events.trigger('input-blur'); event.preventDefault();
} }
}); }
}); });
element.find('div').on('click', function() {
input[0].focus();
});
} }
}; };
}]); }]);
/** /**
* @ngdoc directive * @ngdoc directive
* @name autoComplete * @name autoComplete
...@@ -388,14 +389,23 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func ...@@ -388,14 +389,23 @@ tagsInput.directive('tagsInput', ["$timeout","$document","tagsInputConfig", func
* @param {boolean=} [highlightMatchedText=true] Flag indicating that the matched text will be highlighted in the * @param {boolean=} [highlightMatchedText=true] Flag indicating that the matched text will be highlighted in the
* suggestions list. * suggestions list.
* @param {number=} [maxResultsToShow=10] Maximum number of results to be displayed at a time. * @param {number=} [maxResultsToShow=10] Maximum number of results to be displayed at a time.
* @param {boolean=} [loadOnDownArrow=false] Flag indicating that the source option will be evaluated when the down arrow
* key is pressed and the suggestion list is closed. The current input value
* is available as $query.
* @param {boolean=} {loadOnEmpty=false} Flag indicating that the source option will be evaluated when the input content
* becomes empty. The $query variable will be passed to the expression as an empty string.
* @param {boolean=} {loadOnFocus=false} Flag indicating that the source option will be evaluated when the input element
* gains focus. The current input value is available as $query.
* @param {boolean=} [selectFirstMatch=true] Flag indicating that the first match will be automatically selected once
* the suggestion list is shown.
*/ */
tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputConfig", function($document, $timeout, $sce, tagsInputConfig) { tagsInput.directive('autoComplete', ["$document","$timeout","$sce","$q","tagsInputConfig","tiUtil", function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil) {
function SuggestionList(loadFn, options) { function SuggestionList(loadFn, options) {
var self = {}, debouncedLoadId, getDifference, lastPromise; var self = {}, getDifference, lastPromise;
getDifference = function(array1, array2) { getDifference = function(array1, array2) {
return array1.filter(function(item) { return array1.filter(function(item) {
return !findInObjectArray(array2, item, options.tagsInput.displayProperty); return !tiUtil.findInObjectArray(array2, item, options.tagsInput.displayProperty);
}); });
}; };
...@@ -407,44 +417,40 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -407,44 +417,40 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
self.index = -1; self.index = -1;
self.selected = null; self.selected = null;
self.query = null; self.query = null;
$timeout.cancel(debouncedLoadId);
}; };
self.show = function() { self.show = function() {
self.selected = null; if (options.selectFirstMatch) {
self.select(0);
}
else {
self.selected = null;
}
self.visible = true; self.visible = true;
}; };
self.load = function(query, tags) { self.load = tiUtil.debounce(function(query, tags) {
if (query.length < options.minLength) { self.query = query;
self.reset();
return;
}
$timeout.cancel(debouncedLoadId); var promise = $q.when(loadFn({ $query: query }));
debouncedLoadId = $timeout(function() { lastPromise = promise;
self.query = query;
var promise = loadFn({ $query: query }); promise.then(function(items) {
lastPromise = promise; if (promise !== lastPromise) {
return;
}
promise.then(function(items) { items = tiUtil.makeObjectArray(items.data || items, options.tagsInput.displayProperty);
if (promise !== lastPromise) { items = getDifference(items, tags);
return; self.items = items.slice(0, options.maxResultsToShow);
}
items = makeObjectArray(items.data || items, options.tagsInput.displayProperty); if (self.items.length > 0) {
items = getDifference(items, tags); self.show();
self.items = items.slice(0, options.maxResultsToShow); }
else {
self.reset();
}
});
}, options.debounceDelay);
if (self.items.length > 0) {
self.show();
}
else {
self.reset();
}
});
}, options.debounceDelay, false);
};
self.selectNext = function() { self.selectNext = function() {
self.select(++self.index); self.select(++self.index);
}; };
...@@ -467,12 +473,6 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -467,12 +473,6 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
return self; return self;
} }
function encodeHTML(value) {
return value.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
return { return {
restrict: 'E', restrict: 'E',
require: '^tagsInput', require: '^tagsInput',
...@@ -480,13 +480,17 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -480,13 +480,17 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
templateUrl: 'ngTagsInput/auto-complete.html', templateUrl: 'ngTagsInput/auto-complete.html',
link: function(scope, element, attrs, tagsInputCtrl) { link: function(scope, element, attrs, tagsInputCtrl) {
var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down], var hotkeys = [KEYS.enter, KEYS.tab, KEYS.escape, KEYS.up, KEYS.down],
suggestionList, tagsInput, options, getItemText, documentClick; suggestionList, tagsInput, options, getItem, getDisplayText, shouldLoadSuggestions;
tagsInputConfig.load('autoComplete', scope, attrs, { tagsInputConfig.load('autoComplete', scope, attrs, {
debounceDelay: [Number, 100], debounceDelay: [Number, 100],
minLength: [Number, 3], minLength: [Number, 3],
highlightMatchedText: [Boolean, true], highlightMatchedText: [Boolean, true],
maxResultsToShow: [Number, 10] maxResultsToShow: [Number, 10],
loadOnDownArrow: [Boolean, false],
loadOnEmpty: [Boolean, false],
loadOnFocus: [Boolean, false],
selectFirstMatch: [Boolean, true]
}); });
options = scope.options; options = scope.options;
...@@ -496,12 +500,25 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -496,12 +500,25 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
suggestionList = new SuggestionList(scope.source, options); suggestionList = new SuggestionList(scope.source, options);
getItemText = function(item) { getItem = function(item) {
return item[options.tagsInput.displayProperty]; return item[options.tagsInput.displayProperty];
}; };
getDisplayText = function(item) {
return tiUtil.safeToString(getItem(item));
};
shouldLoadSuggestions = function(value) {
return value && value.length >= options.minLength || !value && options.loadOnEmpty;
};
scope.suggestionList = suggestionList; scope.suggestionList = suggestionList;
scope.addSuggestionByIndex = function(index) {
suggestionList.select(index);
scope.addSuggestion();
};
scope.addSuggestion = function() { scope.addSuggestion = function() {
var added = false; var added = false;
...@@ -516,51 +533,45 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -516,51 +533,45 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
}; };
scope.highlight = function(item) { scope.highlight = function(item) {
var text = getItemText(item); var text = getDisplayText(item);
text = encodeHTML(text); text = tiUtil.encodeHTML(text);
if (options.highlightMatchedText) { if (options.highlightMatchedText) {
text = replaceAll(text, encodeHTML(suggestionList.query), '<em>$&</em>'); text = tiUtil.safeHighlight(text, tiUtil.encodeHTML(suggestionList.query));
} }
return $sce.trustAsHtml(text); return $sce.trustAsHtml(text);
}; };
scope.track = function(item) { scope.track = function(item) {
return getItemText(item); return getItem(item);
}; };
tagsInput tagsInput
.on('tag-added invalid-tag', function() { .on('tag-added invalid-tag input-blur', function() {
suggestionList.reset(); suggestionList.reset();
}) })
.on('input-change', function(value) { .on('input-change', function(value) {
if (value) { if (shouldLoadSuggestions(value)) {
suggestionList.load(value, tagsInput.getTags()); suggestionList.load(value, tagsInput.getTags());
} else { }
else {
suggestionList.reset(); suggestionList.reset();
} }
}) })
.on('input-keydown', function(e) { .on('input-focus', function() {
var key, handled; var value = tagsInput.getCurrentTagText();
if (options.loadOnFocus && shouldLoadSuggestions(value)) {
suggestionList.load(value, tagsInput.getTags());
}
})
.on('input-keydown', function(event) {
var key = event.keyCode,
handled = false;
if (hotkeys.indexOf(e.keyCode) === -1) { if (hotkeys.indexOf(key) === -1) {
return; return;
} }
// This hack is needed because jqLite doesn't implement stopImmediatePropagation properly.
// I've sent a PR to Angular addressing this issue and hopefully it'll be fixed soon.
// https://github.com/angular/angular.js/pull/4833
var immediatePropagationStopped = false;
e.stopImmediatePropagation = function() {
immediatePropagationStopped = true;
e.stopPropagation();
};
e.isImmediatePropagationStopped = function() {
return immediatePropagationStopped;
};
if (suggestionList.visible) { if (suggestionList.visible) {
key = e.keyCode;
handled = false;
if (key === KEYS.down) { if (key === KEYS.down) {
suggestionList.selectNext(); suggestionList.selectNext();
...@@ -577,34 +588,25 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon ...@@ -577,34 +588,25 @@ tagsInput.directive('autoComplete', ["$document","$timeout","$sce","tagsInputCon
else if (key === KEYS.enter || key === KEYS.tab) { else if (key === KEYS.enter || key === KEYS.tab) {
handled = scope.addSuggestion(); handled = scope.addSuggestion();
} }
}
if (handled) { else {
e.preventDefault(); if (key === KEYS.down && scope.options.loadOnDownArrow) {
e.stopImmediatePropagation(); suggestionList.load(tagsInput.getCurrentTagText(), tagsInput.getTags());
scope.$apply(); handled = true;
} }
} }
})
.on('input-blur', function() {
suggestionList.reset();
});
documentClick = function() { if (handled) {
if (suggestionList.visible) { event.preventDefault();
suggestionList.reset(); event.stopImmediatePropagation();
scope.$apply(); return false;
} }
}; });
$document.on('click', documentClick);
scope.$on('$destroy', function() {
$document.off('click', documentClick);
});
} }
}; };
}]); }]);
/** /**
* @ngdoc directive * @ngdoc directive
* @name tiTranscludeAppend * @name tiTranscludeAppend
...@@ -629,12 +631,12 @@ tagsInput.directive('tiTranscludeAppend', function() { ...@@ -629,12 +631,12 @@ tagsInput.directive('tiTranscludeAppend', function() {
* @description * @description
* Automatically sets the input's width so its content is always visible. Used internally by tagsInput directive. * Automatically sets the input's width so its content is always visible. Used internally by tagsInput directive.
*/ */
tagsInput.directive('tiAutosize', function() { tagsInput.directive('tiAutosize', ["tagsInputConfig", function(tagsInputConfig) {
return { return {
restrict: 'A', restrict: 'A',
require: 'ngModel', require: 'ngModel',
link: function(scope, element, attrs, ctrl) { link: function(scope, element, attrs, ctrl) {
var THRESHOLD = 3, var threshold = tagsInputConfig.getTextAutosizeThreshold(),
span, resize; span, resize;
span = angular.element('<span class="input"></span>'); span = angular.element('<span class="input"></span>');
...@@ -659,7 +661,7 @@ tagsInput.directive('tiAutosize', function() { ...@@ -659,7 +661,7 @@ tagsInput.directive('tiAutosize', function() {
span.css('display', 'none'); span.css('display', 'none');
} }
element.css('width', width ? width + THRESHOLD + 'px' : ''); element.css('width', width ? width + threshold + 'px' : '');
return originalValue; return originalValue;
}; };
...@@ -674,6 +676,24 @@ tagsInput.directive('tiAutosize', function() { ...@@ -674,6 +676,24 @@ tagsInput.directive('tiAutosize', function() {
}); });
} }
}; };
}]);
/**
* @ngdoc directive
* @name tiBindAttrs
* @module ngTagsInput
*
* @description
* Binds attributes to expressions. Used internally by tagsInput directive.
*/
tagsInput.directive('tiBindAttrs', function() {
return function(scope, element, attrs) {
scope.$watch(attrs.tiBindAttrs, function(value) {
angular.forEach(value, function(value, key) {
attrs.$set(key, value);
});
}, true);
};
}); });
/** /**
...@@ -686,7 +706,9 @@ tagsInput.directive('tiAutosize', function() { ...@@ -686,7 +706,9 @@ tagsInput.directive('tiAutosize', function() {
* initialize options from HTML attributes. * initialize options from HTML attributes.
*/ */
tagsInput.provider('tagsInputConfig', function() { tagsInput.provider('tagsInputConfig', function() {
var globalDefaults = {}, interpolationStatus = {}; var globalDefaults = {},
interpolationStatus = {},
autosizeThreshold = 3;
/** /**
* @ngdoc method * @ngdoc method
...@@ -720,6 +742,20 @@ tagsInput.provider('tagsInputConfig', function() { ...@@ -720,6 +742,20 @@ tagsInput.provider('tagsInputConfig', function() {
return this; return this;
}; };
/***
* @ngdoc method
* @name setTextAutosizeThreshold
* @methodOf tagsInputConfig
*
* @param {number} threshold Threshold to be used by the tagsInput directive to re-size the input element based on its contents.
*
* @returns {object} The service itself for chaining purposes.
*/
this.setTextAutosizeThreshold = function(threshold) {
autosizeThreshold = threshold;
return this;
};
this.$get = ["$interpolate", function($interpolate) { this.$get = ["$interpolate", function($interpolate) {
var converters = {}; var converters = {};
converters[String] = function(value) { return value; }; converters[String] = function(value) { return value; };
...@@ -729,13 +765,16 @@ tagsInput.provider('tagsInputConfig', function() { ...@@ -729,13 +765,16 @@ tagsInput.provider('tagsInputConfig', function() {
return { return {
load: function(directive, scope, attrs, options) { load: function(directive, scope, attrs, options) {
var defaultValidator = function() { return true; };
scope.options = {}; scope.options = {};
angular.forEach(options, function(value, key) { angular.forEach(options, function(value, key) {
var type, localDefault, converter, getDefault, updateValue; var type, localDefault, validator, converter, getDefault, updateValue;
type = value[0]; type = value[0];
localDefault = value[1]; localDefault = value[1];
validator = value[2] || defaultValidator;
converter = converters[type]; converter = converters[type];
getDefault = function() { getDefault = function() {
...@@ -744,32 +783,131 @@ tagsInput.provider('tagsInputConfig', function() { ...@@ -744,32 +783,131 @@ tagsInput.provider('tagsInputConfig', function() {
}; };
updateValue = function(value) { updateValue = function(value) {
scope.options[key] = value ? converter(value) : getDefault(); scope.options[key] = value && validator(value) ? converter(value) : getDefault();
}; };
if (interpolationStatus[directive] && interpolationStatus[directive][key]) { if (interpolationStatus[directive] && interpolationStatus[directive][key]) {
attrs.$observe(key, function(value) { attrs.$observe(key, function(value) {
updateValue(value); updateValue(value);
scope.events.trigger('option-change', { name: key, newValue: value });
}); });
} }
else { else {
updateValue(attrs[key] && $interpolate(attrs[key])(scope.$parent)); updateValue(attrs[key] && $interpolate(attrs[key])(scope.$parent));
} }
}); });
},
getTextAutosizeThreshold: function() {
return autosizeThreshold;
} }
}; };
}]; }];
}); });
/***
* @ngdoc factory
* @name tiUtil
* @module ngTagsInput
*
* @description
* Helper methods used internally by the directive. Should not be used directly from user code.
*/
tagsInput.factory('tiUtil', ["$timeout", function($timeout) {
var self = {};
self.debounce = function(fn, delay) {
var timeoutId;
return function() {
var args = arguments;
$timeout.cancel(timeoutId);
timeoutId = $timeout(function() { fn.apply(null, args); }, delay);
};
};
self.makeObjectArray = function(array, key) {
array = array || [];
if (array.length > 0 && !angular.isObject(array[0])) {
array.forEach(function(item, index) {
array[index] = {};
array[index][key] = item;
});
}
return array;
};
self.findInObjectArray = function(array, obj, key) {
var item = null;
for (var i = 0; i < array.length; i++) {
// I'm aware of the internationalization issues regarding toLowerCase()
// but I couldn't come up with a better solution right now
if (self.safeToString(array[i][key]).toLowerCase() === self.safeToString(obj[key]).toLowerCase()) {
item = array[i];
break;
}
}
return item;
};
self.safeHighlight = function(str, value) {
if (!value) {
return str;
}
function escapeRegexChars(str) {
return str.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
}
var expression = new RegExp('&[^;]+;|' + escapeRegexChars(value), 'gi');
return str.replace(expression, function(match) {
return match === value ? '<em>' + value + '</em>' : match;
});
};
self.safeToString = function(value) {
return angular.isUndefined(value) || value == null ? '' : value.toString().trim();
};
self.encodeHTML = function(value) {
return value.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
self.simplePubSub = function() {
var events = {};
return {
on: function(names, handler) {
names.split(' ').forEach(function(name) {
if (!events[name]) {
events[name] = [];
}
events[name].push(handler);
});
return this;
},
trigger: function(name, args) {
var handlers = events[name] || [];
handlers.every(function(handler) {
var retVal = handler.call(null, args);
return angular.isUndefined(retVal) || retVal;
});
return this;
}
};
};
return self;
}]);
/* HTML templates */ /* HTML templates */
tagsInput.run(["$templateCache", function($templateCache) { tagsInput.run(["$templateCache", function($templateCache) {
$templateCache.put('ngTagsInput/tags-input.html', $templateCache.put('ngTagsInput/tags-input.html',
"<div class=\"host\" tabindex=\"-1\" ti-transclude-append=\"\"><div class=\"tags\" ng-class=\"{focused: hasFocus}\"><ul class=\"tag-list\"><li class=\"tag-item\" ng-repeat=\"tag in tagList.items track by track(tag)\" ng-class=\"{ selected: tag == tagList.selected }\"><span>{{getDisplayText(tag)}}</span> <a class=\"remove-button\" ng-click=\"tagList.remove($index)\">{{options.removeTagSymbol}}</a></li></ul><input class=\"input\" placeholder=\"{{options.placeholder}}\" tabindex=\"{{options.tabindex}}\" ng-model=\"newTag.text\" ng-change=\"newTagChange()\" ng-trim=\"false\" ng-class=\"{'invalid-tag': newTag.invalid}\" ti-autosize=\"\"></div></div>" "<div class=\"host\" tabindex=\"-1\" ng-click=\"eventHandlers.host.click()\" ti-transclude-append=\"\"><div class=\"tags\" ng-class=\"{focused: hasFocus}\"><ul class=\"tag-list\"><li class=\"tag-item\" ng-repeat=\"tag in tagList.items track by track(tag)\" ng-class=\"{ selected: tag == tagList.selected }\"><span ng-bind=\"getDisplayText(tag)\"></span> <a class=\"remove-button\" ng-click=\"tagList.remove($index)\" ng-bind=\"options.removeTagSymbol\"></a></li></ul><input class=\"input\" ng-model=\"newTag.text\" ng-change=\"eventHandlers.input.change(newTag.text)\" ng-keydown=\"eventHandlers.input.keydown($event)\" ng-focus=\"eventHandlers.input.focus($event)\" ng-blur=\"eventHandlers.input.blur($event)\" ng-paste=\"eventHandlers.input.paste($event)\" ng-trim=\"false\" ng-class=\"{'invalid-tag': newTag.invalid}\" ti-bind-attrs=\"{type: options.type, placeholder: options.placeholder, tabindex: options.tabindex, spellcheck: options.spellcheck}\" ti-autosize=\"\"></div></div>"
); );
$templateCache.put('ngTagsInput/auto-complete.html', $templateCache.put('ngTagsInput/auto-complete.html',
"<div class=\"autocomplete\" ng-show=\"suggestionList.visible\"><ul class=\"suggestion-list\"><li class=\"suggestion-item\" ng-repeat=\"item in suggestionList.items track by track(item)\" ng-class=\"{selected: item == suggestionList.selected}\" ng-click=\"addSuggestion()\" ng-mouseenter=\"suggestionList.select($index)\" ng-bind-html=\"highlight(item)\"></li></ul></div>" "<div class=\"autocomplete\" ng-show=\"suggestionList.visible\"><ul class=\"suggestion-list\"><li class=\"suggestion-item\" ng-repeat=\"item in suggestionList.items track by track(item)\" ng-class=\"{selected: item == suggestionList.selected}\" ng-click=\"addSuggestionByIndex($index)\" ng-mouseenter=\"suggestionList.select($index)\" ng-bind-html=\"highlight(item)\"></li></ul></div>"
); );
}]); }]);
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
@import "mixins/forms"; @import "mixins/forms";
@import "compass/css3/user-interface"; @import "compass/css3/user-interface";
@import "variables";
tags-input { tags-input {
.host { .host {
...@@ -101,3 +102,49 @@ tags-input { ...@@ -101,3 +102,49 @@ tags-input {
} }
} }
tags-input .autocomplete {
margin-top: .3em;
position: absolute;
padding: .3em 0;
z-index: 999;
width: 100%;
background-color: white;
border: thin solid rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0 .3em .6em rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 .3em .6em rgba(0, 0, 0, 0.2);
box-shadow: 0 .3em .6em rgba(0, 0, 0, 0.2);
.suggestion-list {
margin: 0;
padding: 0;
list-style-type: none;
}
.suggestion-item {
padding: .3em .6em;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: $sans-font-family;
color: black;
background-color: white;
em {
font-family: $sans-font-family;
font-weight: bold;
font-style: normal;
color: black;
background-color: white;
}
&.selected {
color: white;
background-color: #0097cf;
em {
color: white;
background-color: #0097cf;
}
}
}
}
...@@ -80,7 +80,11 @@ ...@@ -80,7 +80,11 @@
placeholder="Add tags…" placeholder="Add tags…"
min-length="1" min-length="1"
replace-spaces-with-dashes="false" replace-spaces-with-dashes="false"
enable-editing-last-tag="true"></tags-input> enable-editing-last-tag="true">
<auto-complete source="vm.tagsAutoComplete($query)"
min-length="1"
max-results-to-show="10"></auto-complete>
</tags-input>
</div> </div>
<div class="annotation-section tags tags-read-only" <div class="annotation-section tags tags-read-only"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment