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