]> BookStack Code Mirror - bookstack/blob - resources/assets/js/directives.js
Merge pull request #8 from OsmosysSoftware/feature-181
[bookstack] / resources / assets / js / directives.js
1 "use strict";
2 const DropZone = require("dropzone");
3 const MarkdownIt = require("markdown-it");
4 const mdTasksLists = require('markdown-it-task-lists');
5 const code = require('./code');
6
7 module.exports = function (ngApp, events) {
8
9     /**
10      * Common tab controls using simple jQuery functions.
11      */
12     ngApp.directive('tabContainer', function() {
13         return {
14             restrict: 'A',
15             link: function (scope, element, attrs) {
16                 const $content = element.find('[tab-content]');
17                 const $buttons = element.find('[tab-button]');
18
19                 if (attrs.tabContainer) {
20                     let initial = attrs.tabContainer;
21                     $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
22                     $content.hide().filter(`[tab-content="${initial}"]`).show();
23                 } else {
24                     $content.hide().first().show();
25                     $buttons.first().addClass('selected');
26                 }
27
28                 $buttons.click(function() {
29                     let clickedTab = $(this);
30                     $buttons.removeClass('selected');
31                     $content.hide();
32                     let name = clickedTab.addClass('selected').attr('tab-button');
33                     $content.filter(`[tab-content="${name}"]`).show();
34                 });
35             }
36         };
37     });
38
39     /**
40      * Sub form component to allow inner-form sections to act like their own forms.
41      */
42     ngApp.directive('subForm', function() {
43         return {
44             restrict: 'A',
45             link: function (scope, element, attrs) {
46                 element.on('keypress', e => {
47                     if (e.keyCode === 13) {
48                         submitEvent(e);
49                     }
50                 });
51
52                 element.find('button[type="submit"]').click(submitEvent);
53
54                 function submitEvent(e) {
55                     e.preventDefault();
56                     if (attrs.subForm) scope.$eval(attrs.subForm);
57                 }
58             }
59         };
60     });
61
62     /**
63      * DropZone
64      * Used for uploading images
65      */
66     ngApp.directive('dropZone', [function () {
67         return {
68             restrict: 'E',
69             template: `
70             <div class="dropzone-container">
71                 <div class="dz-message">{{message}}</div>
72             </div>
73             `,
74             scope: {
75                 uploadUrl: '@',
76                 eventSuccess: '=',
77                 eventError: '=',
78                 uploadedTo: '@',
79             },
80             link: function (scope, element, attrs) {
81                 scope.message = attrs.message;
82                 if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
83                 let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
84                     url: scope.uploadUrl,
85                     init: function () {
86                         let dz = this;
87                         dz.on('sending', function (file, xhr, data) {
88                             let token = window.document.querySelector('meta[name=token]').getAttribute('content');
89                             data.append('_token', token);
90                             let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
91                             data.append('uploaded_to', uploadedTo);
92                         });
93                         if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
94                         dz.on('success', function (file, data) {
95                             $(file.previewElement).fadeOut(400, function () {
96                                 dz.removeFile(file);
97                             });
98                         });
99                         if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
100                         dz.on('error', function (file, errorMessage, xhr) {
101                             console.log(errorMessage);
102                             console.log(xhr);
103                             function setMessage(message) {
104                                 $(file.previewElement).find('[data-dz-errormessage]').text(message);
105                             }
106
107                             if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
108                             if (errorMessage.file) setMessage(errorMessage.file[0]);
109
110                         });
111                     }
112                 });
113             }
114         };
115     }]);
116
117     /**
118      * Dropdown
119      * Provides some simple logic to create small dropdown menus
120      */
121     ngApp.directive('dropdown', [function () {
122         return {
123             restrict: 'A',
124             link: function (scope, element, attrs) {
125                 const menu = element.find('ul');
126                 element.find('[dropdown-toggle]').on('click', function () {
127                     menu.show().addClass('anim menuIn');
128                     let inputs = menu.find('input');
129                     let hasInput = inputs.length > 0;
130                     if (hasInput) {
131                         inputs.first().focus();
132                         element.on('keypress', 'input', event => {
133                             if (event.keyCode === 13) {
134                                 event.preventDefault();
135                                 menu.hide();
136                                 menu.removeClass('anim menuIn');
137                                 return false;
138                             }
139                         });
140                     }
141                     element.mouseleave(function () {
142                         menu.hide();
143                         menu.removeClass('anim menuIn');
144                     });
145                 });
146             }
147         };
148     }]);
149
150     /**
151      * TinyMCE
152      * An angular wrapper around the tinyMCE editor.
153      */
154     ngApp.directive('tinymce', ['$timeout', function ($timeout) {
155         return {
156             restrict: 'A',
157             scope: {
158                 tinymce: '=',
159                 mceModel: '=',
160                 mceChange: '='
161             },
162             link: function (scope, element, attrs) {
163
164                 function tinyMceSetup(editor) {
165                     editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
166                         let content = editor.getContent();
167                         $timeout(() => {
168                             scope.mceModel = content;
169                         });
170                         scope.mceChange(content);
171                     });
172
173                     editor.on('keydown', (event) => {
174                         scope.$emit('editor-keydown', event);
175                     });
176
177                     editor.on('init', (e) => {
178                         scope.mceModel = editor.getContent();
179                     });
180
181                     scope.$on('html-update', (event, value) => {
182                         editor.setContent(value);
183                         editor.selection.select(editor.getBody(), true);
184                         editor.selection.collapse(false);
185                         scope.mceModel = editor.getContent();
186                     });
187                 }
188
189                 scope.tinymce.extraSetups.push(tinyMceSetup);
190
191                 // Custom tinyMCE plugins
192                 tinymce.PluginManager.add('customhr', function (editor) {
193                     editor.addCommand('InsertHorizontalRule', function () {
194                         let hrElem = document.createElement('hr');
195                         let cNode = editor.selection.getNode();
196                         let parentNode = cNode.parentNode;
197                         parentNode.insertBefore(hrElem, cNode);
198                     });
199
200                     editor.addButton('hr', {
201                         icon: 'hr',
202                         tooltip: 'Horizontal line',
203                         cmd: 'InsertHorizontalRule'
204                     });
205
206                     editor.addMenuItem('hr', {
207                         icon: 'hr',
208                         text: 'Horizontal line',
209                         cmd: 'InsertHorizontalRule',
210                         context: 'insert'
211                     });
212                 });
213
214                 tinymce.init(scope.tinymce);
215             }
216         }
217     }]);
218
219     const md = new MarkdownIt({html: true});
220     md.use(mdTasksLists, {label: true});
221
222     /**
223      * Markdown input
224      * Handles the logic for just the editor input field.
225      */
226     ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
227         return {
228             restrict: 'A',
229             scope: {
230                 mdModel: '=',
231                 mdChange: '='
232             },
233             link: function (scope, element, attrs) {
234
235                 // Codemirror Setup
236                 element = element.find('textarea').first();
237                 let cm = code.markdownEditor(element[0]);
238
239                 // Custom key commands
240                 let metaKey = code.getMetaKey();
241                 const extraKeys = {};
242                 // Insert Image shortcut
243                 extraKeys[`${metaKey}-Alt-I`] = function(cm) {
244                     let selectedText = cm.getSelection();
245                     let newText = `![${selectedText}](http://)`;
246                     let cursorPos = cm.getCursor('from');
247                     cm.replaceSelection(newText);
248                     cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
249                 };
250                 // Save draft
251                 extraKeys[`${metaKey}-S`] = function(cm) {scope.$emit('save-draft');};
252                 // Show link selector
253                 extraKeys[`Shift-${metaKey}-K`] = function(cm) {showLinkSelector()};
254                 cm.setOption('extraKeys', extraKeys);
255
256                 // Update data on content change
257                 cm.on('change', (instance, changeObj) => {
258                     update(instance);
259                 });
260
261                 // Handle scroll to sync display view
262                 cm.on('scroll', instance => {
263                     // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
264                     let scroll = instance.getScrollInfo();
265                     let atEnd = scroll.top + scroll.clientHeight === scroll.height;
266                     if (atEnd) {
267                         scope.$emit('markdown-scroll', -1);
268                         return;
269                     }
270                     let lineNum = instance.lineAtHeight(scroll.top, 'local');
271                     let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
272                     let parser = new DOMParser();
273                     let doc = parser.parseFromString(md.render(range), 'text/html');
274                     let totalLines = doc.documentElement.querySelectorAll('body > *');
275                     scope.$emit('markdown-scroll', totalLines.length);
276                 });
277
278                 // Handle image paste
279                 cm.on('paste', (cm, event) => {
280                     if (!event.clipboardData || !event.clipboardData.items) return;
281                     for (let i = 0; i < event.clipboardData.items.length; i++) {
282                         uploadImage(event.clipboardData.items[i].getAsFile());
283                     }
284                 });
285
286                 // Handle images on drag-drop
287                 cm.on('drop', (cm, event) => {
288                     event.stopPropagation();
289                     event.preventDefault();
290                     let cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
291                     cm.setCursor(cursorPos);
292                     if (!event.dataTransfer || !event.dataTransfer.files) return;
293                     for (let i = 0; i < event.dataTransfer.files.length; i++) {
294                         uploadImage(event.dataTransfer.files[i]);
295                     }
296                 });
297
298                 // Helper to replace editor content
299                 function replaceContent(search, replace) {
300                     let text = cm.getValue();
301                     let cursor = cm.listSelections();
302                     cm.setValue(text.replace(search, replace));
303                     cm.setSelections(cursor);
304                 }
305
306                 // Handle image upload and add image into markdown content
307                 function uploadImage(file) {
308                     if (file === null || file.type.indexOf('image') !== 0) return;
309                     let ext = 'png';
310
311                     if (file.name) {
312                         let fileNameMatches = file.name.match(/\.(.+)$/);
313                         if (fileNameMatches.length > 1) ext = fileNameMatches[1];
314                     }
315
316                     // Insert image into markdown
317                     let id = "image-" + Math.random().toString(16).slice(2);
318                     let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
319                     let selectedText = cm.getSelection();
320                     let placeHolderText = `![${selectedText}](${placeholderImage})`;
321                     cm.replaceSelection(placeHolderText);
322
323                     let remoteFilename = "image-" + Date.now() + "." + ext;
324                     let formData = new FormData();
325                     formData.append('file', file, remoteFilename);
326
327                     window.$http.post('/images/gallery/upload', formData).then(resp => {
328                         replaceContent(placeholderImage, resp.data.thumbs.display);
329                     }).catch(err => {
330                         events.emit('error', trans('errors.image_upload_error'));
331                         replaceContent(placeHolderText, selectedText);
332                         console.log(err);
333                     });
334                 }
335
336                 // Show the popup link selector and insert a link when finished
337                 function showLinkSelector() {
338                     let cursorPos = cm.getCursor('from');
339                     window.showEntityLinkSelector(entity => {
340                         let selectedText = cm.getSelection() || entity.name;
341                         let newText = `[${selectedText}](${entity.link})`;
342                         cm.focus();
343                         cm.replaceSelection(newText);
344                         cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
345                     });
346                 }
347
348                 // Show the image manager and handle image insertion
349                 function showImageManager() {
350                     let cursorPos = cm.getCursor('from');
351                     window.ImageManager.showExternal(image => {
352                         let selectedText = cm.getSelection();
353                         let newText = "![" + (selectedText || image.name) + "](" + image.thumbs.display + ")";
354                         cm.focus();
355                         cm.replaceSelection(newText);
356                         cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
357                     });
358                 }
359
360                 // Update the data models and rendered output
361                 function update(instance) {
362                     let content = instance.getValue();
363                     element.val(content);
364                     $timeout(() => {
365                         scope.mdModel = content;
366                         scope.mdChange(md.render(content));
367                     });
368                 }
369                 update(cm);
370
371                 // Listen to commands from parent scope
372                 scope.$on('md-insert-link', showLinkSelector);
373                 scope.$on('md-insert-image', showImageManager);
374                 scope.$on('markdown-update', (event, value) => {
375                     cm.setValue(value);
376                     element.val(value);
377                     scope.mdModel = value;
378                     scope.mdChange(md.render(value));
379                 });
380
381             }
382         }
383     }]);
384
385     /**
386      * Markdown Editor
387      * Handles all functionality of the markdown editor.
388      */
389     ngApp.directive('markdownEditor', ['$timeout', '$rootScope', function ($timeout, $rootScope) {
390         return {
391             restrict: 'A',
392             link: function (scope, element, attrs) {
393
394                 // Editor Elements
395                 const $display = element.find('.markdown-display').first();
396                 const $insertImage = element.find('button[data-action="insertImage"]');
397                 const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
398
399                 // Prevent markdown display link click redirect
400                 $display.on('click', 'a', function(event) {
401                     event.preventDefault();
402                     window.open(this.getAttribute('href'));
403                 });
404
405                 // Editor UI Actions
406                 $insertEntityLink.click(e => {scope.$broadcast('md-insert-link');});
407                 $insertImage.click(e => {scope.$broadcast('md-insert-image');});
408
409                 // Handle scroll sync event from editor scroll
410                 $rootScope.$on('markdown-scroll', (event, lineCount) => {
411                     let elems = $display[0].children[0].children;
412                     if (elems.length > lineCount) {
413                         let topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
414                         $display.animate({
415                             scrollTop: topElem.offsetTop
416                         }, {queue: false, duration: 200, easing: 'linear'});
417                     }
418                 });
419             }
420         }
421     }]);
422
423     /**
424      * Page Editor Toolbox
425      * Controls all functionality for the sliding toolbox
426      * on the page edit view.
427      */
428     ngApp.directive('toolbox', [function () {
429         return {
430             restrict: 'A',
431             link: function (scope, elem, attrs) {
432
433                 // Get common elements
434                 const $buttons = elem.find('[toolbox-tab-button]');
435                 const $content = elem.find('[toolbox-tab-content]');
436                 const $toggle = elem.find('[toolbox-toggle]');
437
438                 // Handle toolbox toggle click
439                 $toggle.click((e) => {
440                     elem.toggleClass('open');
441                 });
442
443                 // Set an active tab/content by name
444                 function setActive(tabName, openToolbox) {
445                     $buttons.removeClass('active');
446                     $content.hide();
447                     $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
448                     $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
449                     if (openToolbox) elem.addClass('open');
450                 }
451
452                 // Set the first tab content active on load
453                 setActive($content.first().attr('toolbox-tab-content'), false);
454
455                 // Handle tab button click
456                 $buttons.click(function (e) {
457                     let name = $(this).attr('toolbox-tab-button');
458                     setActive(name, true);
459                 });
460             }
461         }
462     }]);
463
464     /**
465      * Tag Autosuggestions
466      * Listens to child inputs and provides autosuggestions depending on field type
467      * and input. Suggestions provided by server.
468      */
469     ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
470         return {
471             restrict: 'A',
472             link: function (scope, elem, attrs) {
473
474                 // Local storage for quick caching.
475                 const localCache = {};
476
477                 // Create suggestion element
478                 const suggestionBox = document.createElement('ul');
479                 suggestionBox.className = 'suggestion-box';
480                 suggestionBox.style.position = 'absolute';
481                 suggestionBox.style.display = 'none';
482                 const $suggestionBox = $(suggestionBox);
483
484                 // General state tracking
485                 let isShowing = false;
486                 let currentInput = false;
487                 let active = 0;
488
489                 // Listen to input events on autosuggest fields
490                 elem.on('input focus', '[autosuggest]', function (event) {
491                     let $input = $(this);
492                     let val = $input.val();
493                     let url = $input.attr('autosuggest');
494                     let type = $input.attr('autosuggest-type');
495
496                     // Add name param to request if for a value
497                     if (type.toLowerCase() === 'value') {
498                         let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
499                         let nameVal = $nameInput.val();
500                         if (nameVal !== '') {
501                             url += '?name=' + encodeURIComponent(nameVal);
502                         }
503                     }
504
505                     let suggestionPromise = getSuggestions(val.slice(0, 3), url);
506                     suggestionPromise.then(suggestions => {
507                         if (val.length === 0) {
508                             displaySuggestions($input, suggestions.slice(0, 6));
509                         } else  {
510                             suggestions = suggestions.filter(item => {
511                                 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
512                             }).slice(0, 4);
513                             displaySuggestions($input, suggestions);
514                         }
515                     });
516                 });
517
518                 // Hide autosuggestions when input loses focus.
519                 // Slight delay to allow clicks.
520                 let lastFocusTime = 0;
521                 elem.on('blur', '[autosuggest]', function (event) {
522                     let startTime = Date.now();
523                     setTimeout(() => {
524                         if (lastFocusTime < startTime) {
525                             $suggestionBox.hide();
526                             isShowing = false;
527                         }
528                     }, 200)
529                 });
530                 elem.on('focus', '[autosuggest]', function (event) {
531                     lastFocusTime = Date.now();
532                 });
533
534                 elem.on('keydown', '[autosuggest]', function (event) {
535                     if (!isShowing) return;
536
537                     let suggestionElems = suggestionBox.childNodes;
538                     let suggestCount = suggestionElems.length;
539
540                     // Down arrow
541                     if (event.keyCode === 40) {
542                         let newActive = (active === suggestCount - 1) ? 0 : active + 1;
543                         changeActiveTo(newActive, suggestionElems);
544                     }
545                     // Up arrow
546                     else if (event.keyCode === 38) {
547                         let newActive = (active === 0) ? suggestCount - 1 : active - 1;
548                         changeActiveTo(newActive, suggestionElems);
549                     }
550                     // Enter or tab key
551                     else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
552                         currentInput[0].value = suggestionElems[active].textContent;
553                         currentInput.focus();
554                         $suggestionBox.hide();
555                         isShowing = false;
556                         if (event.keyCode === 13) {
557                             event.preventDefault();
558                             return false;
559                         }
560                     }
561                 });
562
563                 // Change the active suggestion to the given index
564                 function changeActiveTo(index, suggestionElems) {
565                     suggestionElems[active].className = '';
566                     active = index;
567                     suggestionElems[active].className = 'active';
568                 }
569
570                 // Display suggestions on a field
571                 let prevSuggestions = [];
572
573                 function displaySuggestions($input, suggestions) {
574
575                     // Hide if no suggestions
576                     if (suggestions.length === 0) {
577                         $suggestionBox.hide();
578                         isShowing = false;
579                         prevSuggestions = suggestions;
580                         return;
581                     }
582
583                     // Otherwise show and attach to input
584                     if (!isShowing) {
585                         $suggestionBox.show();
586                         isShowing = true;
587                     }
588                     if ($input !== currentInput) {
589                         $suggestionBox.detach();
590                         $input.after($suggestionBox);
591                         currentInput = $input;
592                     }
593
594                     // Return if no change
595                     if (prevSuggestions.join() === suggestions.join()) {
596                         prevSuggestions = suggestions;
597                         return;
598                     }
599
600                     // Build suggestions
601                     $suggestionBox[0].innerHTML = '';
602                     for (let i = 0; i < suggestions.length; i++) {
603                         let suggestion = document.createElement('li');
604                         suggestion.textContent = suggestions[i];
605                         suggestion.onclick = suggestionClick;
606                         if (i === 0) {
607                             suggestion.className = 'active';
608                             active = 0;
609                         }
610                         $suggestionBox[0].appendChild(suggestion);
611                     }
612
613                     prevSuggestions = suggestions;
614                 }
615
616                 // Suggestion click event
617                 function suggestionClick(event) {
618                     currentInput[0].value = this.textContent;
619                     currentInput.focus();
620                     $suggestionBox.hide();
621                     isShowing = false;
622                 }
623
624                 // Get suggestions & cache
625                 function getSuggestions(input, url) {
626                     let hasQuery = url.indexOf('?') !== -1;
627                     let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
628
629                     // Get from local cache if exists
630                     if (typeof localCache[searchUrl] !== 'undefined') {
631                         return new Promise((resolve, reject) => {
632                             resolve(localCache[searchUrl]);
633                         });
634                     }
635
636                     return $http.get(searchUrl).then(response => {
637                         localCache[searchUrl] = response.data;
638                         return response.data;
639                     });
640                 }
641
642             }
643         }
644     }]);
645
646     ngApp.directive('entityLinkSelector', [function($http) {
647         return {
648             restrict: 'A',
649             link: function(scope, element, attrs) {
650
651                 const selectButton = element.find('.entity-link-selector-confirm');
652                 let callback = false;
653                 let entitySelection = null;
654
655                 // Handle entity selection change, Stores the selected entity locally
656                 function entitySelectionChange(entity) {
657                     entitySelection = entity;
658                     if (entity === null) {
659                         selectButton.attr('disabled', 'true');
660                     } else {
661                         selectButton.removeAttr('disabled');
662                     }
663                 }
664                 events.listen('entity-select-change', entitySelectionChange);
665
666                 // Handle selection confirm button click
667                 selectButton.click(event => {
668                     hide();
669                     if (entitySelection !== null) callback(entitySelection);
670                 });
671
672                 // Show selector interface
673                 function show() {
674                     element.fadeIn(240);
675                 }
676
677                 // Hide selector interface
678                 function hide() {
679                     element.fadeOut(240);
680                 }
681
682                 // Listen to confirmation of entity selections (doubleclick)
683                 events.listen('entity-select-confirm', entity => {
684                     hide();
685                     callback(entity);
686                 });
687
688                 // Show entity selector, Accessible globally, and store the callback
689                 window.showEntityLinkSelector = function(passedCallback) {
690                     show();
691                     callback = passedCallback;
692                 };
693
694             }
695         };
696     }]);
697
698
699     ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
700         return {
701             restrict: 'A',
702             scope: true,
703             link: function (scope, element, attrs) {
704                 scope.loading = true;
705                 scope.entityResults = false;
706                 scope.search = '';
707
708                 // Add input for forms
709                 const input = element.find('[entity-selector-input]').first();
710
711                 // Detect double click events
712                 let lastClick = 0;
713                 function isDoubleClick() {
714                     let now = Date.now();
715                     let answer = now - lastClick < 300;
716                     lastClick = now;
717                     return answer;
718                 }
719
720                 // Listen to entity item clicks
721                 element.on('click', '.entity-list a', function(event) {
722                     event.preventDefault();
723                     event.stopPropagation();
724                     let item = $(this).closest('[data-entity-type]');
725                     itemSelect(item, isDoubleClick());
726                 });
727                 element.on('click', '[data-entity-type]', function(event) {
728                     itemSelect($(this), isDoubleClick());
729                 });
730
731                 // Select entity action
732                 function itemSelect(item, doubleClick) {
733                     let entityType = item.attr('data-entity-type');
734                     let entityId = item.attr('data-entity-id');
735                     let isSelected = !item.hasClass('selected') || doubleClick;
736                     element.find('.selected').removeClass('selected').removeClass('primary-background');
737                     if (isSelected) item.addClass('selected').addClass('primary-background');
738                     let newVal = isSelected ? `${entityType}:${entityId}` : '';
739                     input.val(newVal);
740
741                     if (!isSelected) {
742                         events.emit('entity-select-change', null);
743                     }
744
745                     if (!doubleClick && !isSelected) return;
746
747                     let link = item.find('.entity-list-item-link').attr('href');
748                     let name = item.find('.entity-list-item-name').text();
749
750                     if (doubleClick) {
751                         events.emit('entity-select-confirm', {
752                             id: Number(entityId),
753                             name: name,
754                             link: link
755                         });
756                     }
757
758                     if (isSelected) {
759                         events.emit('entity-select-change', {
760                             id: Number(entityId),
761                             name: name,
762                             link: link
763                         });
764                     }
765                 }
766
767                 // Get search url with correct types
768                 function getSearchUrl() {
769                     let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
770                     return window.baseUrl(`/ajax/search/entities?types=${types}`);
771                 }
772
773                 // Get initial contents
774                 $http.get(getSearchUrl()).then(resp => {
775                     scope.entityResults = $sce.trustAsHtml(resp.data);
776                     scope.loading = false;
777                 });
778
779                 // Search when typing
780                 scope.searchEntities = function() {
781                     scope.loading = true;
782                     input.val('');
783                     let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
784                     $http.get(url).then(resp => {
785                         scope.entityResults = $sce.trustAsHtml(resp.data);
786                         scope.loading = false;
787                     });
788                 };
789             }
790         };
791     }]);
792 };