]> git.mxchange.org Git - friendica.git/blob - view/theme/frio/frameworks/friendica-tagsinput/friendica-tagsinput.js
af722d789ee04c39de7b46086fb152bc666b54e4
[friendica.git] / view / theme / frio / frameworks / friendica-tagsinput / friendica-tagsinput.js
1 /*
2  * friendica-tagsinput v0.8.0
3  * Based on bootstrap-tagsinput v0.8.0
4  *
5  * Adds:
6  * - optional thumbnail
7  * - copying source input element class to the pseudo-input element
8  *
9  */
10
11 (function ($) {
12   "use strict";
13
14   var defaultOptions = {
15     tagClass: function(item) {
16       return 'label label-info';
17     },
18     focusClass: 'focus',
19     itemValue: function(item) {
20       return item ? item.toString() : item;
21     },
22     itemText: function(item) {
23       return this.itemValue(item);
24     },
25     itemTitle: function(item) {
26       return null;
27     },
28     itemThumb: function(item) {
29       return null;
30     },
31     freeInput: true,
32     addOnBlur: true,
33     maxTags: undefined,
34     maxChars: undefined,
35     confirmKeys: [13, 44],
36     delimiter: ',',
37     delimiterRegex: null,
38     cancelConfirmKeysOnEmpty: false,
39     onTagExists: function(item, $tag) {
40       $tag.hide().fadeIn();
41     },
42     trimValue: false,
43     allowDuplicates: false,
44     triggerChange: true
45   };
46
47   /**
48    * Constructor function
49    */
50   function TagsInput(element, options) {
51     this.isInit = true;
52     this.itemsArray = [];
53
54     this.$element = $(element);
55     this.$element.hide();
56
57     this.isSelect = (element.tagName === 'SELECT');
58     this.multiple = (this.isSelect && element.hasAttribute('multiple'));
59     this.objectItems = options && options.itemValue;
60     this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : '';
61     this.inputSize = Math.max(1, this.placeholderText.length);
62
63     this.$container = $('<div class="friendica-tagsinput"></div>');
64     this.$container.addClass(this.$element.attr('class'));
65     this.$input = $('<input type="text" placeholder="' + this.placeholderText + '"/>').appendTo(this.$container);
66
67     this.$element.before(this.$container);
68
69     this.build(options);
70     this.isInit = false;
71   }
72
73   TagsInput.prototype = {
74     constructor: TagsInput,
75
76     /**
77      * Adds the given item as a new tag. Pass true to dontPushVal to prevent
78      * updating the elements val()
79      */
80     add: function(item, dontPushVal, options) {
81       let self = this;
82
83       if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags)
84         return;
85
86       // Ignore falsey values, except false
87       if (item !== false && !item)
88         return;
89
90       // Trim value
91       if (typeof item === "string" && self.options.trimValue) {
92         item = $.trim(item);
93       }
94
95       // Throw an error when trying to add an object while the itemValue option was not set
96       if (typeof item === "object" && !self.objectItems)
97         throw("Can't add objects when itemValue option is not set");
98
99       // Ignore strings only containg whitespace
100       if (item.toString().match(/^\s*$/))
101         return;
102
103       // If SELECT but not multiple, remove current tag
104       if (self.isSelect && !self.multiple && self.itemsArray.length > 0)
105         self.remove(self.itemsArray[0]);
106
107       if (typeof item === "string" && this.$element[0].tagName === 'INPUT') {
108         var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter;
109         var items = item.split(delimiter);
110         if (items.length > 1) {
111           for (var i = 0; i < items.length; i++) {
112             this.add(items[i], true);
113           }
114
115           if (!dontPushVal)
116             self.pushVal(self.options.triggerChange);
117           return;
118         }
119       }
120
121       var itemValue = self.options.itemValue(item),
122           itemText = self.options.itemText(item),
123           tagClass = self.options.tagClass(item),
124           itemTitle = self.options.itemTitle(item),
125           itemThumb = self.options.itemThumb(item);
126
127       // Ignore items allready added
128       var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0];
129       if (existing && !self.options.allowDuplicates) {
130         // Invoke onTagExists
131         if (self.options.onTagExists) {
132           var $existingTag = $(".tag", self.$container).filter(function() { return $(this).data("item") === existing; });
133           self.options.onTagExists(item, $existingTag);
134         }
135         return;
136       }
137
138       // if length greater than limit
139       if (self.items().toString().length + item.length + 1 > self.options.maxInputLength)
140         return;
141
142       // raise beforeItemAdd arg
143       var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options});
144       self.$element.trigger(beforeItemAddEvent);
145       if (beforeItemAddEvent.cancel)
146         return;
147
148       // register item in internal array and map
149       self.itemsArray.push(item);
150
151       // add a tag element
152       var $tag = $('<span class="tag ' + htmlEncode(tagClass) + (itemTitle !== null ? ('" title="' + itemTitle) : '') + '">' +
153           (itemThumb !== null ? '<img src="' + itemThumb + '" alt="">' : '') +
154           htmlEncode(itemText) + '<span data-role="remove"></span>' +
155           '</span>');
156       $tag.data('item', item);
157       self.findInputWrapper().before($tag);
158       $tag.after(' ');
159
160       // Check to see if the tag exists in its raw or uri-encoded form
161       var optionExists = (
162           $('option[value="' + encodeURIComponent(itemValue) + '"]', self.$element).length ||
163           $('option[value="' + htmlEncode(itemValue) + '"]', self.$element).length
164       );
165
166       // add <option /> if item represents a value not present in one of the <select />'s options
167       if (self.isSelect && !optionExists) {
168         var $option = $('<option selected>' + htmlEncode(itemText) + '</option>');
169         $option.data('item', item);
170         $option.attr('value', itemValue);
171         self.$element.append($option);
172       }
173
174       if (!dontPushVal)
175         self.pushVal(self.options.triggerChange);
176
177       // Add class when reached maxTags
178       if (self.options.maxTags === self.itemsArray.length || self.items().toString().length === self.options.maxInputLength)
179         self.$container.addClass('friendica-tagsinput-max');
180
181       // If using typeahead, once the tag has been added, clear the typeahead value so it does not stick around in the input.
182       if ($('.typeahead, .twitter-typeahead', self.$container).length) {
183         self.$input.typeahead('val', '');
184       }
185
186       if (this.isInit) {
187         self.$element.trigger($.Event('itemAddedOnInit', { item: item, options: options }));
188       } else {
189         self.$element.trigger($.Event('itemAdded', { item: item, options: options }));
190       }
191     },
192
193     /**
194      * Removes the given item. Pass true to dontPushVal to prevent updating the
195      * elements val()
196      */
197     remove: function(item, dontPushVal, options) {
198       var self = this;
199
200       if (self.objectItems) {
201         if (typeof item === "object")
202           item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) ==  self.options.itemValue(item); } );
203         else
204           item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) ==  item; } );
205
206         item = item[item.length-1];
207       }
208
209       if (item) {
210         var beforeItemRemoveEvent = $.Event('beforeItemRemove', { item: item, cancel: false, options: options });
211         self.$element.trigger(beforeItemRemoveEvent);
212         if (beforeItemRemoveEvent.cancel)
213           return;
214
215         $('.tag', self.$container).filter(function() { return $(this).data('item') === item; }).remove();
216         $('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove();
217         if($.inArray(item, self.itemsArray) !== -1)
218           self.itemsArray.splice($.inArray(item, self.itemsArray), 1);
219       }
220
221       if (!dontPushVal)
222         self.pushVal(self.options.triggerChange);
223
224       // Remove class when reached maxTags
225       if (self.options.maxTags > self.itemsArray.length)
226         self.$container.removeClass('friendica-tagsinput-max');
227
228       self.$element.trigger($.Event('itemRemoved',  { item: item, options: options }));
229     },
230
231     /**
232      * Removes all items
233      */
234     removeAll: function() {
235       var self = this;
236
237       $('.tag', self.$container).remove();
238       $('option', self.$element).remove();
239
240       while(self.itemsArray.length > 0)
241         self.itemsArray.pop();
242
243       self.pushVal(self.options.triggerChange);
244     },
245
246     /**
247      * Refreshes the tags so they match the text/value of their corresponding
248      * item.
249      */
250     refresh: function() {
251       var self = this;
252       $('.tag', self.$container).each(function() {
253         var $tag = $(this),
254             item = $tag.data('item'),
255             itemValue = self.options.itemValue(item),
256             itemText = self.options.itemText(item),
257             tagClass = self.options.tagClass(item);
258
259           // Update tag's class and inner text
260           $tag.attr('class', null);
261           $tag.addClass('tag ' + htmlEncode(tagClass));
262           $tag.contents().filter(function() {
263             return this.nodeType == 3;
264           })[0].nodeValue = htmlEncode(itemText);
265
266           if (self.isSelect) {
267             var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; });
268             option.attr('value', itemValue);
269           }
270       });
271     },
272
273     /**
274      * Returns the items added as tags
275      */
276     items: function() {
277       return this.itemsArray;
278     },
279
280     /**
281      * Assembly value by retrieving the value of each item, and set it on the
282      * element.
283      */
284     pushVal: function() {
285       var self = this,
286           val = $.map(self.items(), function(item) {
287             return self.options.itemValue(item).toString();
288           });
289
290       self.$element.val(val, true);
291
292       if (self.options.triggerChange)
293         self.$element.trigger('change');
294     },
295
296     /**
297      * Initializes the tags input behaviour on the element
298      */
299     build: function(options) {
300       var self = this;
301
302       self.options = $.extend({}, defaultOptions, options);
303       // When itemValue is set, freeInput should always be false
304       if (self.objectItems)
305         self.options.freeInput = false;
306
307       makeOptionItemFunction(self.options, 'itemValue');
308       makeOptionItemFunction(self.options, 'itemText');
309       makeOptionItemFunction(self.options, 'itemThumb');
310       makeOptionFunction(self.options, 'tagClass');
311
312       // Typeahead Bootstrap version 2.3.2
313       if (self.options.typeahead) {
314         var typeahead = self.options.typeahead || {};
315
316         makeOptionFunction(typeahead, 'source');
317
318         self.$input.typeahead($.extend({}, typeahead, {
319           source: function (query, process) {
320             function processItems(items) {
321               var texts = [];
322
323               for (var i = 0; i < items.length; i++) {
324                 var text = self.options.itemText(items[i]);
325                 map[text] = items[i];
326                 texts.push(text);
327               }
328               process(texts);
329             }
330
331             this.map = {};
332             var map = this.map,
333                 data = typeahead.source(query);
334
335             if ($.isFunction(data.success)) {
336               // support for Angular callbacks
337               data.success(processItems);
338             } else if ($.isFunction(data.then)) {
339               // support for Angular promises
340               data.then(processItems);
341             } else {
342               // support for functions and jquery promises
343               $.when(data)
344                   .then(processItems);
345             }
346           },
347           updater: function (text) {
348             self.add(this.map[text]);
349             return this.map[text];
350           },
351           matcher: function (text) {
352             return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1);
353           },
354           sorter: function (texts) {
355             return texts.sort();
356           },
357           highlighter: function (text) {
358             var regex = new RegExp( '(' + this.query + ')', 'gi' );
359             return text.replace( regex, "<strong>$1</strong>" );
360           }
361         }));
362       }
363
364       // typeahead.js
365       if (self.options.typeaheadjs) {
366         var typeaheadConfig = null;
367         var typeaheadDatasets = {};
368
369         // Determine if main configurations were passed or simply a dataset
370         var typeaheadjs = self.options.typeaheadjs;
371         if ($.isArray(typeaheadjs)) {
372           typeaheadConfig = typeaheadjs[0];
373           typeaheadDatasets = typeaheadjs[1];
374         } else {
375           typeaheadDatasets = typeaheadjs;
376         }
377
378         self.$input.typeahead(typeaheadConfig, typeaheadDatasets).on('typeahead:selected', $.proxy(function (obj, datum) {
379           if (typeaheadDatasets.valueKey)
380             self.add(datum[typeaheadDatasets.valueKey]);
381           else
382             self.add(datum);
383           self.$input.typeahead('val', '');
384         }, self));
385       }
386
387       self.$container.on('click', $.proxy(function(event) {
388         if (! self.$element.attr('disabled')) {
389           self.$input.removeAttr('disabled');
390         }
391         self.$input.focus();
392       }, self));
393
394       if (self.options.addOnBlur && self.options.freeInput) {
395         self.$input.on('focusout', $.proxy(function(event) {
396           // HACK: only process on focusout when no typeahead opened, to
397           //       avoid adding the typeahead text as tag
398           if ($('.typeahead, .twitter-typeahead', self.$container).length === 0) {
399             self.add(self.$input.val());
400             self.$input.val('');
401           }
402         }, self));
403       }
404
405       // Toggle the 'focus' css class on the container when it has focus
406       self.$container.on({
407         focusin: function() {
408           self.$container.addClass(self.options.focusClass);
409         },
410         focusout: function() {
411           self.$container.removeClass(self.options.focusClass);
412         },
413       });
414
415       self.$container.on('keydown', 'input', $.proxy(function(event) {
416         var $input = $(event.target),
417             $inputWrapper = self.findInputWrapper();
418
419         if (self.$element.attr('disabled')) {
420           self.$input.attr('disabled', 'disabled');
421           return;
422         }
423
424         switch (event.which) {
425             // BACKSPACE
426           case 8:
427             if (doGetCaretPosition($input[0]) === 0) {
428               var prev = $inputWrapper.prev();
429               if (prev.length) {
430                 self.remove(prev.data('item'));
431               }
432             }
433             break;
434
435             // DELETE
436           case 46:
437             if (doGetCaretPosition($input[0]) === 0) {
438               var next = $inputWrapper.next();
439               if (next.length) {
440                 self.remove(next.data('item'));
441               }
442             }
443             break;
444
445             // LEFT ARROW
446           case 37:
447             // Try to move the input before the previous tag
448             var $prevTag = $inputWrapper.prev();
449             if ($input.val().length === 0 && $prevTag[0]) {
450               $prevTag.before($inputWrapper);
451               $input.focus();
452             }
453             break;
454             // RIGHT ARROW
455           case 39:
456             // Try to move the input after the next tag
457             var $nextTag = $inputWrapper.next();
458             if ($input.val().length === 0 && $nextTag[0]) {
459               $nextTag.after($inputWrapper);
460               $input.focus();
461             }
462             break;
463           default:
464             // ignore
465         }
466
467         // Reset internal input's size
468         var textLength = $input.val().length,
469             wordSpace = Math.ceil(textLength / 5),
470             size = textLength + wordSpace + 1;
471         $input.attr('size', Math.max(this.inputSize, $input.val().length));
472       }, self));
473
474       self.$container.on('keypress', 'input', $.proxy(function(event) {
475         var $input = $(event.target);
476
477         if (self.$element.attr('disabled')) {
478           self.$input.attr('disabled', 'disabled');
479           return;
480         }
481
482         var text = $input.val(),
483             maxLengthReached = self.options.maxChars && text.length >= self.options.maxChars;
484         if (keyCombinationInList(event, self.options.confirmKeys) || maxLengthReached) {
485           // Only attempt to add a tag if there is data in the field
486           if (self.options.freeInput && text.length !== 0) {
487             self.add(maxLengthReached ? text.substr(0, self.options.maxChars) : text);
488             $input.val('');
489           }
490
491           // If the field is empty, let the event triggered fire as usual
492           if (self.options.cancelConfirmKeysOnEmpty === false) {
493             event.preventDefault();
494           }
495         }
496
497         // Reset internal input's size
498         var textLength = $input.val().length,
499             wordSpace = Math.ceil(textLength / 5),
500             size = textLength + wordSpace + 1;
501         $input.attr('size', Math.max(this.inputSize, $input.val().length));
502       }, self));
503
504       // Remove icon clicked
505       self.$container.on('click', '[data-role=remove]', $.proxy(function(event) {
506         if (self.$element.attr('disabled')) {
507           return;
508         }
509         self.remove($(event.target).closest('.tag').data('item'));
510       }, self));
511
512       // Only add existing value as tags when using strings as tags
513       if (self.options.itemValue === defaultOptions.itemValue) {
514         if (self.$element[0].tagName === 'INPUT') {
515           self.add(self.$element.val());
516         } else {
517           $('option', self.$element).each(function() {
518             self.add($(this).attr('value'), true);
519           });
520         }
521       }
522     },
523
524     /**
525      * Removes all tagsinput behaviour and unregsiter all event handlers
526      */
527     destroy: function() {
528       var self = this;
529
530       // Unbind events
531       self.$container.off('keypress', 'input');
532       self.$container.off('click', '[role=remove]');
533
534       self.$container.remove();
535       self.$element.removeData('tagsinput');
536       self.$element.show();
537     },
538
539     /**
540      * Sets focus on the tagsinput
541      */
542     focus: function() {
543       this.$input.focus();
544     },
545
546     /**
547      * Returns the internal input element
548      */
549     input: function() {
550       return this.$input;
551     },
552
553     /**
554      * Returns the element which is wrapped around the internal input. This
555      * is normally the $container, but typeahead.js moves the $input element.
556      */
557     findInputWrapper: function() {
558       var elt = this.$input[0],
559           container = this.$container[0];
560       while(elt && elt.parentNode !== container)
561         elt = elt.parentNode;
562
563       return $(elt);
564     }
565   };
566
567   /**
568    * Register JQuery plugin
569    */
570   $.fn.tagsinput = function(arg1, arg2, arg3) {
571     var results = [];
572
573     this.each(function() {
574       var tagsinput = $(this).data('tagsinput');
575       // Initialize a new tags input
576       if (!tagsinput) {
577           tagsinput = new TagsInput(this, arg1);
578           $(this).data('tagsinput', tagsinput);
579           results.push(tagsinput);
580
581           if (this.tagName === 'SELECT') {
582               $('option', $(this)).attr('selected', 'selected');
583           }
584
585           // Init tags from $(this).val()
586           $(this).val($(this).val());
587       } else if (!arg1 && !arg2) {
588           // tagsinput already exists
589           // no function, trying to init
590           results.push(tagsinput);
591       } else if(tagsinput[arg1] !== undefined) {
592           // Invoke function on existing tags input
593             if(tagsinput[arg1].length === 3 && arg3 !== undefined){
594                var retVal = tagsinput[arg1](arg2, null, arg3);
595             }else{
596                var retVal = tagsinput[arg1](arg2);
597             }
598           if (retVal !== undefined)
599               results.push(retVal);
600       }
601     });
602
603     if ( typeof arg1 == 'string') {
604       // Return the results from the invoked function calls
605       return results.length > 1 ? results : results[0];
606     } else {
607       return results;
608     }
609   };
610
611   $.fn.tagsinput.Constructor = TagsInput;
612
613   /**
614    * Most options support both a string or number as well as a function as
615    * option value. This function makes sure that the option with the given
616    * key in the given options is wrapped in a function
617    */
618   function makeOptionItemFunction(options, key) {
619     if (typeof options[key] !== 'function') {
620       var propertyName = options[key];
621       options[key] = function(item) { return item[propertyName]; };
622     }
623   }
624   function makeOptionFunction(options, key) {
625     if (typeof options[key] !== 'function') {
626       var value = options[key];
627       options[key] = function() { return value; };
628     }
629   }
630   /**
631    * HtmlEncodes the given value
632    */
633   var htmlEncodeContainer = $('<div />');
634   function htmlEncode(value) {
635     if (value) {
636       return htmlEncodeContainer.text(value).html();
637     } else {
638       return '';
639     }
640   }
641
642   /**
643    * Returns the position of the caret in the given input field
644    * http://flightschool.acylt.com/devnotes/caret-position-woes/
645    */
646   function doGetCaretPosition(oField) {
647     var iCaretPos = 0;
648     if (document.selection) {
649       oField.focus ();
650       var oSel = document.selection.createRange();
651       oSel.moveStart ('character', -oField.value.length);
652       iCaretPos = oSel.text.length;
653     } else if (oField.selectionStart || oField.selectionStart == '0') {
654       iCaretPos = oField.selectionStart;
655     }
656     return (iCaretPos);
657   }
658
659   /**
660     * Returns boolean indicates whether user has pressed an expected key combination.
661     * @param object keyPressEvent: JavaScript event object, refer
662     *     http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html
663     * @param object lookupList: expected key combinations, as in:
664     *     [13, {which: 188, shiftKey: true}]
665     */
666   function keyCombinationInList(keyPressEvent, lookupList) {
667       var found = false;
668       $.each(lookupList, function (index, keyCombination) {
669           if (typeof (keyCombination) === 'number' && keyPressEvent.which === keyCombination) {
670               found = true;
671               return false;
672           }
673
674           if (keyPressEvent.which === keyCombination.which) {
675               var alt = !keyCombination.hasOwnProperty('altKey') || keyPressEvent.altKey === keyCombination.altKey,
676                   shift = !keyCombination.hasOwnProperty('shiftKey') || keyPressEvent.shiftKey === keyCombination.shiftKey,
677                   ctrl = !keyCombination.hasOwnProperty('ctrlKey') || keyPressEvent.ctrlKey === keyCombination.ctrlKey;
678               if (alt && shift && ctrl) {
679                   found = true;
680                   return false;
681               }
682           }
683       });
684
685       return found;
686   }
687
688   /**
689    * Initialize tagsinput behaviour on inputs and selects which have
690    * data-role=tagsinput
691    */
692   $(function() {
693     $("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput();
694   });
695 })(window.jQuery);