]> BookStack Code Mirror - bookstack/blob - resources/assets/js/directives.js
Merge fixes from branch 'v0.12'
[bookstack] / resources / assets / js / directives.js
1 "use strict";
2 const DropZone = require('dropzone');
3 const markdown = require('marked');
4
5 module.exports = function (ngApp, events) {
6
7     /**
8      * Toggle Switches
9      * Has basic on/off functionality.
10      * Use string values of 'true' & 'false' to dictate the current state.
11      */
12     ngApp.directive('toggleSwitch', function () {
13         return {
14             restrict: 'A',
15             template: `
16             <div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
17                 <input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
18                 <div class="switch-handle"></div>
19             </div>
20             `,
21             scope: true,
22             link: function (scope, element, attrs) {
23                 scope.name = attrs.name;
24                 scope.value = attrs.value;
25                 scope.isActive = scope.value == true && scope.value != 'false';
26                 scope.value = (scope.value == true && scope.value != 'false') ? 'true' : 'false';
27
28                 scope.switch = function () {
29                     scope.isActive = !scope.isActive;
30                     scope.value = scope.isActive ? 'true' : 'false';
31                 }
32
33             }
34         };
35     });
36
37     /**
38      * Common tab controls using simple jQuery functions.
39      */
40     ngApp.directive('tabContainer', function() {
41         return {
42             restrict: 'A',
43             link: function (scope, element, attrs) {
44                 const $content = element.find('[tab-content]');
45                 const $buttons = element.find('[tab-button]');
46
47                 if (attrs.tabContainer) {
48                     let initial = attrs.tabContainer;
49                     $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
50                     $content.hide().filter(`[tab-content="${initial}"]`).show();
51                 } else {
52                     $content.hide().first().show();
53                     $buttons.first().addClass('selected');
54                 }
55
56                 $buttons.click(function() {
57                     let clickedTab = $(this);
58                     $buttons.removeClass('selected');
59                     $content.hide();
60                     let name = clickedTab.addClass('selected').attr('tab-button');
61                     $content.filter(`[tab-content="${name}"]`).show();
62                 });
63             }
64         };
65     });
66
67     /**
68      * Sub form component to allow inner-form sections to act like thier own forms.
69      */
70     ngApp.directive('subForm', function() {
71         return {
72             restrict: 'A',
73             link: function (scope, element, attrs) {
74                 element.on('keypress', e => {
75                     if (e.keyCode === 13) {
76                         submitEvent(e);
77                     }
78                 });
79
80                 element.find('button[type="submit"]').click(submitEvent);
81
82                 function submitEvent(e) {
83                     e.preventDefault()
84                     if (attrs.subForm) scope.$eval(attrs.subForm);
85                 }
86             }
87         };
88     });
89
90
91     /**
92      * Image Picker
93      * Is a simple front-end interface that connects to an ImageManager if present.
94      */
95     ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
96         return {
97             restrict: 'E',
98             template: `
99             <div class="image-picker">
100                 <div>
101                     <img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
102                     <img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
103                 </div>
104                 <button class="button" type="button" ng-click="showImageManager()">Select Image</button>
105                 <br>
106
107                 <button class="text-button" ng-click="reset()" type="button">Reset</button>
108                 <span ng-show="showRemove" class="sep">|</span>
109                 <button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
110
111                 <input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
112             </div>
113             `,
114             scope: {
115                 name: '@',
116                 resizeHeight: '@',
117                 resizeWidth: '@',
118                 resizeCrop: '@',
119                 showRemove: '=',
120                 currentImage: '@',
121                 currentId: '@',
122                 defaultImage: '@',
123                 imageClass: '@'
124             },
125             link: function (scope, element, attrs) {
126                 let usingIds = typeof scope.currentId !== 'undefined' || scope.currentId === 'false';
127                 scope.image = scope.currentImage;
128                 scope.value = scope.currentImage || '';
129                 if (usingIds) scope.value = scope.currentId;
130
131                 function setImage(imageModel, imageUrl) {
132                     scope.image = imageUrl;
133                     scope.value = usingIds ? imageModel.id : imageUrl;
134                 }
135
136                 scope.reset = function () {
137                     setImage({id: 0}, scope.defaultImage);
138                 };
139
140                 scope.remove = function () {
141                     scope.image = 'none';
142                     scope.value = 'none';
143                 };
144
145                 scope.showImageManager = function () {
146                     imageManagerService.show((image) => {
147                         scope.updateImageFromModel(image);
148                     });
149                 };
150
151                 scope.updateImageFromModel = function (model) {
152                     let isResized = scope.resizeWidth && scope.resizeHeight;
153
154                     if (!isResized) {
155                         scope.$apply(() => {
156                             setImage(model, model.url);
157                         });
158                         return;
159                     }
160
161                     let cropped = scope.resizeCrop ? 'true' : 'false';
162                     let requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
163                     requestString = window.baseUrl(requestString);
164                     $http.get(requestString).then((response) => {
165                         setImage(model, response.data.url);
166                     });
167                 };
168
169             }
170         };
171     }]);
172
173     /**
174      * DropZone
175      * Used for uploading images
176      */
177     ngApp.directive('dropZone', [function () {
178         return {
179             restrict: 'E',
180             template: `
181             <div class="dropzone-container">
182                 <div class="dz-message">Drop files or click here to upload</div>
183             </div>
184             `,
185             scope: {
186                 uploadUrl: '@',
187                 eventSuccess: '=',
188                 eventError: '=',
189                 uploadedTo: '@'
190             },
191             link: function (scope, element, attrs) {
192                 if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
193                 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
194                     url: scope.uploadUrl,
195                     init: function () {
196                         var dz = this;
197                         dz.on('sending', function (file, xhr, data) {
198                             var token = window.document.querySelector('meta[name=token]').getAttribute('content');
199                             data.append('_token', token);
200                             var uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
201                             data.append('uploaded_to', uploadedTo);
202                         });
203                         if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
204                         dz.on('success', function (file, data) {
205                             $(file.previewElement).fadeOut(400, function () {
206                                 dz.removeFile(file);
207                             });
208                         });
209                         if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
210                         dz.on('error', function (file, errorMessage, xhr) {
211                             console.log(errorMessage);
212                             console.log(xhr);
213                             function setMessage(message) {
214                                 $(file.previewElement).find('[data-dz-errormessage]').text(message);
215                             }
216
217                             if (xhr.status === 413) setMessage('The server does not allow uploads of this size. Please try a smaller file.');
218                             if (errorMessage.file) setMessage(errorMessage.file[0]);
219
220                         });
221                     }
222                 });
223             }
224         };
225     }]);
226
227     /**
228      * Dropdown
229      * Provides some simple logic to create small dropdown menus
230      */
231     ngApp.directive('dropdown', [function () {
232         return {
233             restrict: 'A',
234             link: function (scope, element, attrs) {
235                 const menu = element.find('ul');
236                 element.find('[dropdown-toggle]').on('click', function () {
237                     menu.show().addClass('anim menuIn');
238                     let inputs = menu.find('input');
239                     let hasInput = inputs.length > 0;
240                     if (hasInput) {
241                         inputs.first().focus();
242                         element.on('keypress', 'input', event => {
243                             if (event.keyCode === 13) {
244                                 event.preventDefault();
245                                 menu.hide();
246                                 menu.removeClass('anim menuIn');
247                                 return false;
248                             }
249                         });
250                     }
251                     element.mouseleave(function () {
252                         menu.hide();
253                         menu.removeClass('anim menuIn');
254                     });
255                 });
256             }
257         };
258     }]);
259
260     /**
261      * TinyMCE
262      * An angular wrapper around the tinyMCE editor.
263      */
264     ngApp.directive('tinymce', ['$timeout', function ($timeout) {
265         return {
266             restrict: 'A',
267             scope: {
268                 tinymce: '=',
269                 mceModel: '=',
270                 mceChange: '='
271             },
272             link: function (scope, element, attrs) {
273
274                 function tinyMceSetup(editor) {
275                     editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
276                         var content = editor.getContent();
277                         $timeout(() => {
278                             scope.mceModel = content;
279                         });
280                         scope.mceChange(content);
281                     });
282
283                     editor.on('keydown', (event) => {
284                         scope.$emit('editor-keydown', event);
285                     });
286
287                     editor.on('init', (e) => {
288                         scope.mceModel = editor.getContent();
289                     });
290
291                     scope.$on('html-update', (event, value) => {
292                         editor.setContent(value);
293                         editor.selection.select(editor.getBody(), true);
294                         editor.selection.collapse(false);
295                         scope.mceModel = editor.getContent();
296                     });
297                 }
298
299                 scope.tinymce.extraSetups.push(tinyMceSetup);
300
301                 // Custom tinyMCE plugins
302                 tinymce.PluginManager.add('customhr', function (editor) {
303                     editor.addCommand('InsertHorizontalRule', function () {
304                         var hrElem = document.createElement('hr');
305                         var cNode = editor.selection.getNode();
306                         var parentNode = cNode.parentNode;
307                         parentNode.insertBefore(hrElem, cNode);
308                     });
309
310                     editor.addButton('hr', {
311                         icon: 'hr',
312                         tooltip: 'Horizontal line',
313                         cmd: 'InsertHorizontalRule'
314                     });
315
316                     editor.addMenuItem('hr', {
317                         icon: 'hr',
318                         text: 'Horizontal line',
319                         cmd: 'InsertHorizontalRule',
320                         context: 'insert'
321                     });
322                 });
323
324                 tinymce.init(scope.tinymce);
325             }
326         }
327     }]);
328
329     /**
330      * Markdown input
331      * Handles the logic for just the editor input field.
332      */
333     ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
334         return {
335             restrict: 'A',
336             scope: {
337                 mdModel: '=',
338                 mdChange: '='
339             },
340             link: function (scope, element, attrs) {
341
342                 // Set initial model content
343                 element = element.find('textarea').first();
344                 let content = element.val();
345                 scope.mdModel = content;
346                 scope.mdChange(markdown(content));
347
348                 element.on('change input', (event) => {
349                     content = element.val();
350                     $timeout(() => {
351                         scope.mdModel = content;
352                         scope.mdChange(markdown(content));
353                     });
354                 });
355
356                 scope.$on('markdown-update', (event, value) => {
357                     element.val(value);
358                     scope.mdModel = value;
359                     scope.mdChange(markdown(value));
360                 });
361
362             }
363         }
364     }]);
365
366     /**
367      * Markdown Editor
368      * Handles all functionality of the markdown editor.
369      */
370     ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
371         return {
372             restrict: 'A',
373             link: function (scope, element, attrs) {
374
375                 // Elements
376                 const input = element.find('[markdown-input] textarea').first();
377                 const display = element.find('.markdown-display').first();
378                 const insertImage = element.find('button[data-action="insertImage"]');
379                 const insertEntityLink = element.find('button[data-action="insertEntityLink"]')
380
381                 let currentCaretPos = 0;
382
383                 input.blur(event => {
384                     currentCaretPos = input[0].selectionStart;
385                 });
386
387                 // Scroll sync
388                 let inputScrollHeight,
389                     inputHeight,
390                     displayScrollHeight,
391                     displayHeight;
392
393                 function setScrollHeights() {
394                     inputScrollHeight = input[0].scrollHeight;
395                     inputHeight = input.height();
396                     displayScrollHeight = display[0].scrollHeight;
397                     displayHeight = display.height();
398                 }
399
400                 setTimeout(() => {
401                     setScrollHeights();
402                 }, 200);
403                 window.addEventListener('resize', setScrollHeights);
404                 let scrollDebounceTime = 800;
405                 let lastScroll = 0;
406                 input.on('scroll', event => {
407                     let now = Date.now();
408                     if (now - lastScroll > scrollDebounceTime) {
409                         setScrollHeights()
410                     }
411                     let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
412                     let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
413                     display.scrollTop(displayScrollY);
414                     lastScroll = now;
415                 });
416
417                 // Editor key-presses
418                 input.keydown(event => {
419                     // Insert image shortcut
420                     if (event.which === 73 && event.ctrlKey && event.shiftKey) {
421                         event.preventDefault();
422                         let caretPos = input[0].selectionStart;
423                         let currentContent = input.val();
424                         const mdImageText = "![](http://)";
425                         input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
426                         input.focus();
427                         input[0].selectionStart = caretPos + ("![](".length);
428                         input[0].selectionEnd = caretPos + ('![](http://'.length);
429                         return;
430                     }
431
432                     // Insert entity link shortcut
433                     if (event.which === 75 && event.ctrlKey && event.shiftKey) {
434                         showLinkSelector();
435                         return;
436                     }
437
438                     // Pass key presses to controller via event
439                     scope.$emit('editor-keydown', event);
440                 });
441
442                 // Insert image from image manager
443                 insertImage.click(event => {
444                     window.ImageManager.showExternal(image => {
445                         let caretPos = currentCaretPos;
446                         let currentContent = input.val();
447                         let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")";
448                         input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
449                         input.change();
450                     });
451                 });
452
453                 function showLinkSelector() {
454                     window.showEntityLinkSelector((entity) => {
455                         let selectionStart = currentCaretPos;
456                         let selectionEnd = input[0].selectionEnd;
457                         let textSelected = (selectionEnd !== selectionStart);
458                         let currentContent = input.val();
459
460                         if (textSelected) {
461                             let selectedText = currentContent.substring(selectionStart, selectionEnd);
462                             let linkText = `[${selectedText}](${entity.link})`;
463                             input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
464                         } else {
465                             let linkText = ` [${entity.name}](${entity.link}) `;
466                             input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
467                         }
468                         input.change();
469                     });
470                 }
471                 insertEntityLink.click(showLinkSelector);
472
473                 // Upload and insert image on paste
474                 function editorPaste(e) {
475                     e = e.originalEvent;
476                     if (!e.clipboardData) return
477                     var items = e.clipboardData.items;
478                     if (!items) return;
479                     for (var i = 0; i < items.length; i++) {
480                         uploadImage(items[i].getAsFile());
481                     }
482                 }
483
484                 input.on('paste', editorPaste);
485
486                 // Handle image drop, Uploads images to BookStack.
487                 function handleImageDrop(event) {
488                     event.stopPropagation();
489                     event.preventDefault();
490                     let files = event.originalEvent.dataTransfer.files;
491                     for (let i = 0; i < files.length; i++) {
492                         uploadImage(files[i]);
493                     }
494                 }
495
496                 input.on('drop', handleImageDrop);
497
498                 // Handle image upload and add image into markdown content
499                 function uploadImage(file) {
500                     if (file.type.indexOf('image') !== 0) return;
501                     var formData = new FormData();
502                     var ext = 'png';
503                     var xhr = new XMLHttpRequest();
504
505                     if (file.name) {
506                         var fileNameMatches = file.name.match(/\.(.+)$/);
507                         if (fileNameMatches) {
508                             ext = fileNameMatches[1];
509                         }
510                     }
511
512                     // Insert image into markdown
513                     let id = "image-" + Math.random().toString(16).slice(2);
514                     let selectStart = input[0].selectionStart;
515                     let selectEnd = input[0].selectionEnd;
516                     let content = input[0].value;
517                     let selectText = content.substring(selectStart, selectEnd);
518                     let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
519                     let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
520                     input[0].value = content.substring(0, selectStart) +  innerContent + content.substring(selectEnd);
521
522                     input.focus();
523                     input[0].selectionStart = selectStart;
524                     input[0].selectionEnd = selectStart;
525
526                     let remoteFilename = "image-" + Date.now() + "." + ext;
527                     formData.append('file', file, remoteFilename);
528                     formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
529
530                     xhr.open('POST', window.baseUrl('/images/gallery/upload'));
531                     xhr.onload = function () {
532                         let selectStart = input[0].selectionStart;
533                         if (xhr.status === 200 || xhr.status === 201) {
534                             var result = JSON.parse(xhr.responseText);
535                             input[0].value = input[0].value.replace(placeholderImage, result.thumbs.display);
536                             input.change();
537                         } else {
538                             console.log('An error occurred uploading the image');
539                             console.log(xhr.responseText);
540                             input[0].value = input[0].value.replace(innerContent, '');
541                             input.change();
542                         }
543                         input.focus();
544                         input[0].selectionStart = selectStart;
545                         input[0].selectionEnd = selectStart;
546                     };
547                     xhr.send(formData);
548                 }
549
550             }
551         }
552     }]);
553
554     /**
555      * Page Editor Toolbox
556      * Controls all functionality for the sliding toolbox
557      * on the page edit view.
558      */
559     ngApp.directive('toolbox', [function () {
560         return {
561             restrict: 'A',
562             link: function (scope, elem, attrs) {
563
564                 // Get common elements
565                 const $buttons = elem.find('[toolbox-tab-button]');
566                 const $content = elem.find('[toolbox-tab-content]');
567                 const $toggle = elem.find('[toolbox-toggle]');
568
569                 // Handle toolbox toggle click
570                 $toggle.click((e) => {
571                     elem.toggleClass('open');
572                 });
573
574                 // Set an active tab/content by name
575                 function setActive(tabName, openToolbox) {
576                     $buttons.removeClass('active');
577                     $content.hide();
578                     $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
579                     $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
580                     if (openToolbox) elem.addClass('open');
581                 }
582
583                 // Set the first tab content active on load
584                 setActive($content.first().attr('toolbox-tab-content'), false);
585
586                 // Handle tab button click
587                 $buttons.click(function (e) {
588                     let name = $(this).attr('toolbox-tab-button');
589                     setActive(name, true);
590                 });
591             }
592         }
593     }]);
594
595     /**
596      * Tag Autosuggestions
597      * Listens to child inputs and provides autosuggestions depending on field type
598      * and input. Suggestions provided by server.
599      */
600     ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
601         return {
602             restrict: 'A',
603             link: function (scope, elem, attrs) {
604
605                 // Local storage for quick caching.
606                 const localCache = {};
607
608                 // Create suggestion element
609                 const suggestionBox = document.createElement('ul');
610                 suggestionBox.className = 'suggestion-box';
611                 suggestionBox.style.position = 'absolute';
612                 suggestionBox.style.display = 'none';
613                 const $suggestionBox = $(suggestionBox);
614
615                 // General state tracking
616                 let isShowing = false;
617                 let currentInput = false;
618                 let active = 0;
619
620                 // Listen to input events on autosuggest fields
621                 elem.on('input focus', '[autosuggest]', function (event) {
622                     let $input = $(this);
623                     let val = $input.val();
624                     let url = $input.attr('autosuggest');
625                     let type = $input.attr('autosuggest-type');
626
627                     // Add name param to request if for a value
628                     if (type.toLowerCase() === 'value') {
629                         let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
630                         let nameVal = $nameInput.val();
631                         if (nameVal !== '') {
632                             url += '?name=' + encodeURIComponent(nameVal);
633                         }
634                     }
635
636                     let suggestionPromise = getSuggestions(val.slice(0, 3), url);
637                     suggestionPromise.then(suggestions => {
638                         if (val.length === 0) {
639                             displaySuggestions($input, suggestions.slice(0, 6));
640                         } else  {
641                             suggestions = suggestions.filter(item => {
642                                 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
643                             }).slice(0, 4);
644                             displaySuggestions($input, suggestions);
645                         }
646                     });
647                 });
648
649                 // Hide autosuggestions when input loses focus.
650                 // Slight delay to allow clicks.
651                 let lastFocusTime = 0;
652                 elem.on('blur', '[autosuggest]', function (event) {
653                     let startTime = Date.now();
654                     setTimeout(() => {
655                         if (lastFocusTime < startTime) {
656                             $suggestionBox.hide();
657                             isShowing = false;
658                         }
659                     }, 200)
660                 });
661                 elem.on('focus', '[autosuggest]', function (event) {
662                     lastFocusTime = Date.now();
663                 });
664
665                 elem.on('keydown', '[autosuggest]', function (event) {
666                     if (!isShowing) return;
667
668                     let suggestionElems = suggestionBox.childNodes;
669                     let suggestCount = suggestionElems.length;
670
671                     // Down arrow
672                     if (event.keyCode === 40) {
673                         let newActive = (active === suggestCount - 1) ? 0 : active + 1;
674                         changeActiveTo(newActive, suggestionElems);
675                     }
676                     // Up arrow
677                     else if (event.keyCode === 38) {
678                         let newActive = (active === 0) ? suggestCount - 1 : active - 1;
679                         changeActiveTo(newActive, suggestionElems);
680                     }
681                     // Enter or tab key
682                     else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
683                         let text = suggestionElems[active].textContent;
684                         currentInput[0].value = text;
685                         currentInput.focus();
686                         $suggestionBox.hide();
687                         isShowing = false;
688                         if (event.keyCode === 13) {
689                             event.preventDefault();
690                             return false;
691                         }
692                     }
693                 });
694
695                 // Change the active suggestion to the given index
696                 function changeActiveTo(index, suggestionElems) {
697                     suggestionElems[active].className = '';
698                     active = index;
699                     suggestionElems[active].className = 'active';
700                 }
701
702                 // Display suggestions on a field
703                 let prevSuggestions = [];
704
705                 function displaySuggestions($input, suggestions) {
706
707                     // Hide if no suggestions
708                     if (suggestions.length === 0) {
709                         $suggestionBox.hide();
710                         isShowing = false;
711                         prevSuggestions = suggestions;
712                         return;
713                     }
714
715                     // Otherwise show and attach to input
716                     if (!isShowing) {
717                         $suggestionBox.show();
718                         isShowing = true;
719                     }
720                     if ($input !== currentInput) {
721                         $suggestionBox.detach();
722                         $input.after($suggestionBox);
723                         currentInput = $input;
724                     }
725
726                     // Return if no change
727                     if (prevSuggestions.join() === suggestions.join()) {
728                         prevSuggestions = suggestions;
729                         return;
730                     }
731
732                     // Build suggestions
733                     $suggestionBox[0].innerHTML = '';
734                     for (let i = 0; i < suggestions.length; i++) {
735                         var suggestion = document.createElement('li');
736                         suggestion.textContent = suggestions[i];
737                         suggestion.onclick = suggestionClick;
738                         if (i === 0) {
739                             suggestion.className = 'active'
740                             active = 0;
741                         }
742                         ;
743                         $suggestionBox[0].appendChild(suggestion);
744                     }
745
746                     prevSuggestions = suggestions;
747                 }
748
749                 // Suggestion click event
750                 function suggestionClick(event) {
751                     let text = this.textContent;
752                     currentInput[0].value = text;
753                     currentInput.focus();
754                     $suggestionBox.hide();
755                     isShowing = false;
756                 };
757
758                 // Get suggestions & cache
759                 function getSuggestions(input, url) {
760                     let hasQuery = url.indexOf('?') !== -1;
761                     let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
762
763                     // Get from local cache if exists
764                     if (typeof localCache[searchUrl] !== 'undefined') {
765                         return new Promise((resolve, reject) => {
766                             resolve(localCache[searchUrl]);
767                         });
768                     }
769
770                     return $http.get(searchUrl).then(response => {
771                         localCache[searchUrl] = response.data;
772                         return response.data;
773                     });
774                 }
775
776             }
777         }
778     }]);
779
780     ngApp.directive('entityLinkSelector', [function($http) {
781         return {
782             restict: 'A',
783             link: function(scope, element, attrs) {
784
785                 const selectButton = element.find('.entity-link-selector-confirm');
786                 let callback = false;
787                 let entitySelection = null;
788
789                 // Handle entity selection change, Stores the selected entity locally
790                 function entitySelectionChange(entity) {
791                     entitySelection = entity;
792                     if (entity === null) {
793                         selectButton.attr('disabled', 'true');
794                     } else {
795                         selectButton.removeAttr('disabled');
796                     }
797                 }
798                 events.listen('entity-select-change', entitySelectionChange);
799
800                 // Handle selection confirm button click
801                 selectButton.click(event => {
802                     hide();
803                     if (entitySelection !== null) callback(entitySelection);
804                 });
805
806                 // Show selector interface
807                 function show() {
808                     element.fadeIn(240);
809                 }
810
811                 // Hide selector interface
812                 function hide() {
813                     element.fadeOut(240);
814                 }
815
816                 // Listen to confirmation of entity selections (doubleclick)
817                 events.listen('entity-select-confirm', entity => {
818                     hide();
819                     callback(entity);
820                 });
821
822                 // Show entity selector, Accessible globally, and store the callback
823                 window.showEntityLinkSelector = function(passedCallback) {
824                     show();
825                     callback = passedCallback;
826                 };
827
828             }
829         };
830     }]);
831
832
833     ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
834         return {
835             restrict: 'A',
836             scope: true,
837             link: function (scope, element, attrs) {
838                 scope.loading = true;
839                 scope.entityResults = false;
840                 scope.search = '';
841
842                 // Add input for forms
843                 const input = element.find('[entity-selector-input]').first();
844
845                 // Detect double click events
846                 var lastClick = 0;
847                 function isDoubleClick() {
848                     let now = Date.now();
849                     let answer = now - lastClick < 300;
850                     lastClick = now;
851                     return answer;
852                 }
853
854                 // Listen to entity item clicks
855                 element.on('click', '.entity-list a', function(event) {
856                     event.preventDefault();
857                     event.stopPropagation();
858                     let item = $(this).closest('[data-entity-type]');
859                     itemSelect(item, isDoubleClick());
860                 });
861                 element.on('click', '[data-entity-type]', function(event) {
862                     itemSelect($(this), isDoubleClick());
863                 });
864
865                 // Select entity action
866                 function itemSelect(item, doubleClick) {
867                     let entityType = item.attr('data-entity-type');
868                     let entityId = item.attr('data-entity-id');
869                     let isSelected = !item.hasClass('selected') || doubleClick;
870                     element.find('.selected').removeClass('selected').removeClass('primary-background');
871                     if (isSelected) item.addClass('selected').addClass('primary-background');
872                     let newVal = isSelected ? `${entityType}:${entityId}` : '';
873                     input.val(newVal);
874
875                     if (!isSelected) {
876                         events.emit('entity-select-change', null);
877                     }
878
879                     if (!doubleClick && !isSelected) return;
880
881                     let link = item.find('.entity-list-item-link').attr('href');
882                     let name = item.find('.entity-list-item-name').text();
883
884                     if (doubleClick) {
885                         events.emit('entity-select-confirm', {
886                             id: Number(entityId),
887                             name: name,
888                             link: link
889                         });
890                     }
891
892                     if (isSelected) {
893                         events.emit('entity-select-change', {
894                             id: Number(entityId),
895                             name: name,
896                             link: link
897                         });
898                     }
899                 }
900
901                 // Get search url with correct types
902                 function getSearchUrl() {
903                     let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
904                     return window.baseUrl(`/ajax/search/entities?types=${types}`);
905                 }
906
907                 // Get initial contents
908                 $http.get(getSearchUrl()).then(resp => {
909                     scope.entityResults = $sce.trustAsHtml(resp.data);
910                     scope.loading = false;
911                 });
912
913                 // Search when typing
914                 scope.searchEntities = function() {
915                     scope.loading = true;
916                     input.val('');
917                     let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
918                     $http.get(url).then(resp => {
919                         scope.entityResults = $sce.trustAsHtml(resp.data);
920                         scope.loading = false;
921                     });
922                 };
923             }
924         };
925     }]);
926 };