]> BookStack Code Mirror - bookstack/blob - resources/assets/js/directives.js
#47 - Adds comment level permissions to the front-end.
[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                 // Set initial model content
236                 element = element.find('textarea').first();
237
238                 // Codemirror Setup
239                 let cm = code.markdownEditor(element[0]);
240                 cm.on('change', (instance, changeObj) => {
241                     update(instance);
242                 });
243
244                 cm.on('scroll', instance => {
245                     // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
246                     let scroll = instance.getScrollInfo();
247                     let atEnd = scroll.top + scroll.clientHeight === scroll.height;
248                     if (atEnd) {
249                         scope.$emit('markdown-scroll', -1);
250                         return;
251                     }
252                     let lineNum = instance.lineAtHeight(scroll.top, 'local');
253                     let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
254                     let parser = new DOMParser();
255                     let doc = parser.parseFromString(md.render(range), 'text/html');
256                     let totalLines = doc.documentElement.querySelectorAll('body > *');
257                     scope.$emit('markdown-scroll', totalLines.length);
258                 });
259
260                 function update(instance) {
261                     let content = instance.getValue();
262                     element.val(content);
263                     $timeout(() => {
264                         scope.mdModel = content;
265                         scope.mdChange(md.render(content));
266                     });
267                 }
268                 update(cm);
269
270                 scope.$on('markdown-update', (event, value) => {
271                     cm.setValue(value);
272                     element.val(value);
273                     scope.mdModel = value;
274                     scope.mdChange(md.render(value));
275                 });
276
277             }
278         }
279     }]);
280
281     /**
282      * Markdown Editor
283      * Handles all functionality of the markdown editor.
284      */
285     ngApp.directive('markdownEditor', ['$timeout', '$rootScope', function ($timeout, $rootScope) {
286         return {
287             restrict: 'A',
288             link: function (scope, element, attrs) {
289
290                 // Elements
291                 const $input = element.find('[markdown-input] textarea').first();
292                 const $display = element.find('.markdown-display').first();
293                 const $insertImage = element.find('button[data-action="insertImage"]');
294                 const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
295
296                 // Prevent markdown display link click redirect
297                 $display.on('click', 'a', function(event) {
298                     event.preventDefault();
299                     window.open(this.getAttribute('href'));
300                 });
301
302                 let currentCaretPos = 0;
303
304                 $input.blur(event => {
305                     currentCaretPos = $input[0].selectionStart;
306                 });
307
308                 // Handle scroll sync event from editor scroll
309                 $rootScope.$on('markdown-scroll', (event, lineCount) => {
310                     let elems = $display[0].children[0].children;
311                     if (elems.length > lineCount) {
312                         let topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
313                         $display.animate({
314                             scrollTop: topElem.offsetTop
315                         }, {queue: false, duration: 200, easing: 'linear'});
316                     }
317                 });
318
319                 // Editor key-presses
320                 $input.keydown(event => {
321                     // Insert image shortcut
322                     if (event.which === 73 && event.ctrlKey && event.shiftKey) {
323                         event.preventDefault();
324                         let caretPos = $input[0].selectionStart;
325                         let currentContent = $input.val();
326                         const mdImageText = "![](http://)";
327                         $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
328                         $input.focus();
329                         $input[0].selectionStart = caretPos + ("![](".length);
330                         $input[0].selectionEnd = caretPos + ('![](http://'.length);
331                         return;
332                     }
333
334                     // Insert entity link shortcut
335                     if (event.which === 75 && event.ctrlKey && event.shiftKey) {
336                         showLinkSelector();
337                         return;
338                     }
339
340                     // Pass key presses to controller via event
341                     scope.$emit('editor-keydown', event);
342                 });
343
344                 // Insert image from image manager
345                 $insertImage.click(event => {
346                     window.ImageManager.showExternal(image => {
347                         let caretPos = currentCaretPos;
348                         let currentContent = $input.val();
349                         let mdImageText = "![" + image.name + "](" + image.thumbs.display + ")";
350                         $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
351                         $input.change();
352                     });
353                 });
354
355                 function showLinkSelector() {
356                     window.showEntityLinkSelector((entity) => {
357                         let selectionStart = currentCaretPos;
358                         let selectionEnd = $input[0].selectionEnd;
359                         let textSelected = (selectionEnd !== selectionStart);
360                         let currentContent = $input.val();
361
362                         if (textSelected) {
363                             let selectedText = currentContent.substring(selectionStart, selectionEnd);
364                             let linkText = `[${selectedText}](${entity.link})`;
365                             $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
366                         } else {
367                             let linkText = ` [${entity.name}](${entity.link}) `;
368                             $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
369                         }
370                         $input.change();
371                     });
372                 }
373                 $insertEntityLink.click(showLinkSelector);
374
375                 // Upload and insert image on paste
376                 function editorPaste(e) {
377                     e = e.originalEvent;
378                     if (!e.clipboardData) return
379                     let items = e.clipboardData.items;
380                     if (!items) return;
381                     for (let i = 0; i < items.length; i++) {
382                         uploadImage(items[i].getAsFile());
383                     }
384                 }
385
386                 $input.on('paste', editorPaste);
387
388                 // Handle image drop, Uploads images to BookStack.
389                 function handleImageDrop(event) {
390                     event.stopPropagation();
391                     event.preventDefault();
392                     let files = event.originalEvent.dataTransfer.files;
393                     for (let i = 0; i < files.length; i++) {
394                         uploadImage(files[i]);
395                     }
396                 }
397
398                 $input.on('drop', handleImageDrop);
399
400                 // Handle image upload and add image into markdown content
401                 function uploadImage(file) {
402                     if (file.type.indexOf('image') !== 0) return;
403                     let formData = new FormData();
404                     let ext = 'png';
405                     let xhr = new XMLHttpRequest();
406
407                     if (file.name) {
408                         let fileNameMatches = file.name.match(/\.(.+)$/);
409                         if (fileNameMatches) {
410                             ext = fileNameMatches[1];
411                         }
412                     }
413
414                     // Insert image into markdown
415                     let id = "image-" + Math.random().toString(16).slice(2);
416                     let selectStart = $input[0].selectionStart;
417                     let selectEnd = $input[0].selectionEnd;
418                     let content = $input[0].value;
419                     let selectText = content.substring(selectStart, selectEnd);
420                     let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
421                     let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
422                     $input[0].value = content.substring(0, selectStart) +  innerContent + content.substring(selectEnd);
423
424                     $input.focus();
425                     $input[0].selectionStart = selectStart;
426                     $input[0].selectionEnd = selectStart;
427
428                     let remoteFilename = "image-" + Date.now() + "." + ext;
429                     formData.append('file', file, remoteFilename);
430                     formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
431
432                     xhr.open('POST', window.baseUrl('/images/gallery/upload'));
433                     xhr.onload = function () {
434                         let selectStart = $input[0].selectionStart;
435                         if (xhr.status === 200 || xhr.status === 201) {
436                             let result = JSON.parse(xhr.responseText);
437                             $input[0].value = $input[0].value.replace(placeholderImage, result.thumbs.display);
438                             $input.change();
439                         } else {
440                             console.log(trans('errors.image_upload_error'));
441                             console.log(xhr.responseText);
442                             $input[0].value = $input[0].value.replace(innerContent, '');
443                             $input.change();
444                         }
445                         $input.focus();
446                         $input[0].selectionStart = selectStart;
447                         $input[0].selectionEnd = selectStart;
448                     };
449                     xhr.send(formData);
450                 }
451
452             }
453         }
454     }]);
455
456     /**
457      * Page Editor Toolbox
458      * Controls all functionality for the sliding toolbox
459      * on the page edit view.
460      */
461     ngApp.directive('toolbox', [function () {
462         return {
463             restrict: 'A',
464             link: function (scope, elem, attrs) {
465
466                 // Get common elements
467                 const $buttons = elem.find('[toolbox-tab-button]');
468                 const $content = elem.find('[toolbox-tab-content]');
469                 const $toggle = elem.find('[toolbox-toggle]');
470
471                 // Handle toolbox toggle click
472                 $toggle.click((e) => {
473                     elem.toggleClass('open');
474                 });
475
476                 // Set an active tab/content by name
477                 function setActive(tabName, openToolbox) {
478                     $buttons.removeClass('active');
479                     $content.hide();
480                     $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
481                     $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
482                     if (openToolbox) elem.addClass('open');
483                 }
484
485                 // Set the first tab content active on load
486                 setActive($content.first().attr('toolbox-tab-content'), false);
487
488                 // Handle tab button click
489                 $buttons.click(function (e) {
490                     let name = $(this).attr('toolbox-tab-button');
491                     setActive(name, true);
492                 });
493             }
494         }
495     }]);
496
497     /**
498      * Tag Autosuggestions
499      * Listens to child inputs and provides autosuggestions depending on field type
500      * and input. Suggestions provided by server.
501      */
502     ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
503         return {
504             restrict: 'A',
505             link: function (scope, elem, attrs) {
506
507                 // Local storage for quick caching.
508                 const localCache = {};
509
510                 // Create suggestion element
511                 const suggestionBox = document.createElement('ul');
512                 suggestionBox.className = 'suggestion-box';
513                 suggestionBox.style.position = 'absolute';
514                 suggestionBox.style.display = 'none';
515                 const $suggestionBox = $(suggestionBox);
516
517                 // General state tracking
518                 let isShowing = false;
519                 let currentInput = false;
520                 let active = 0;
521
522                 // Listen to input events on autosuggest fields
523                 elem.on('input focus', '[autosuggest]', function (event) {
524                     let $input = $(this);
525                     let val = $input.val();
526                     let url = $input.attr('autosuggest');
527                     let type = $input.attr('autosuggest-type');
528
529                     // Add name param to request if for a value
530                     if (type.toLowerCase() === 'value') {
531                         let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
532                         let nameVal = $nameInput.val();
533                         if (nameVal !== '') {
534                             url += '?name=' + encodeURIComponent(nameVal);
535                         }
536                     }
537
538                     let suggestionPromise = getSuggestions(val.slice(0, 3), url);
539                     suggestionPromise.then(suggestions => {
540                         if (val.length === 0) {
541                             displaySuggestions($input, suggestions.slice(0, 6));
542                         } else  {
543                             suggestions = suggestions.filter(item => {
544                                 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
545                             }).slice(0, 4);
546                             displaySuggestions($input, suggestions);
547                         }
548                     });
549                 });
550
551                 // Hide autosuggestions when input loses focus.
552                 // Slight delay to allow clicks.
553                 let lastFocusTime = 0;
554                 elem.on('blur', '[autosuggest]', function (event) {
555                     let startTime = Date.now();
556                     setTimeout(() => {
557                         if (lastFocusTime < startTime) {
558                             $suggestionBox.hide();
559                             isShowing = false;
560                         }
561                     }, 200)
562                 });
563                 elem.on('focus', '[autosuggest]', function (event) {
564                     lastFocusTime = Date.now();
565                 });
566
567                 elem.on('keydown', '[autosuggest]', function (event) {
568                     if (!isShowing) return;
569
570                     let suggestionElems = suggestionBox.childNodes;
571                     let suggestCount = suggestionElems.length;
572
573                     // Down arrow
574                     if (event.keyCode === 40) {
575                         let newActive = (active === suggestCount - 1) ? 0 : active + 1;
576                         changeActiveTo(newActive, suggestionElems);
577                     }
578                     // Up arrow
579                     else if (event.keyCode === 38) {
580                         let newActive = (active === 0) ? suggestCount - 1 : active - 1;
581                         changeActiveTo(newActive, suggestionElems);
582                     }
583                     // Enter or tab key
584                     else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
585                         currentInput[0].value = suggestionElems[active].textContent;
586                         currentInput.focus();
587                         $suggestionBox.hide();
588                         isShowing = false;
589                         if (event.keyCode === 13) {
590                             event.preventDefault();
591                             return false;
592                         }
593                     }
594                 });
595
596                 // Change the active suggestion to the given index
597                 function changeActiveTo(index, suggestionElems) {
598                     suggestionElems[active].className = '';
599                     active = index;
600                     suggestionElems[active].className = 'active';
601                 }
602
603                 // Display suggestions on a field
604                 let prevSuggestions = [];
605
606                 function displaySuggestions($input, suggestions) {
607
608                     // Hide if no suggestions
609                     if (suggestions.length === 0) {
610                         $suggestionBox.hide();
611                         isShowing = false;
612                         prevSuggestions = suggestions;
613                         return;
614                     }
615
616                     // Otherwise show and attach to input
617                     if (!isShowing) {
618                         $suggestionBox.show();
619                         isShowing = true;
620                     }
621                     if ($input !== currentInput) {
622                         $suggestionBox.detach();
623                         $input.after($suggestionBox);
624                         currentInput = $input;
625                     }
626
627                     // Return if no change
628                     if (prevSuggestions.join() === suggestions.join()) {
629                         prevSuggestions = suggestions;
630                         return;
631                     }
632
633                     // Build suggestions
634                     $suggestionBox[0].innerHTML = '';
635                     for (let i = 0; i < suggestions.length; i++) {
636                         let suggestion = document.createElement('li');
637                         suggestion.textContent = suggestions[i];
638                         suggestion.onclick = suggestionClick;
639                         if (i === 0) {
640                             suggestion.className = 'active';
641                             active = 0;
642                         }
643                         $suggestionBox[0].appendChild(suggestion);
644                     }
645
646                     prevSuggestions = suggestions;
647                 }
648
649                 // Suggestion click event
650                 function suggestionClick(event) {
651                     currentInput[0].value = this.textContent;
652                     currentInput.focus();
653                     $suggestionBox.hide();
654                     isShowing = false;
655                 }
656
657                 // Get suggestions & cache
658                 function getSuggestions(input, url) {
659                     let hasQuery = url.indexOf('?') !== -1;
660                     let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
661
662                     // Get from local cache if exists
663                     if (typeof localCache[searchUrl] !== 'undefined') {
664                         return new Promise((resolve, reject) => {
665                             resolve(localCache[searchUrl]);
666                         });
667                     }
668
669                     return $http.get(searchUrl).then(response => {
670                         localCache[searchUrl] = response.data;
671                         return response.data;
672                     });
673                 }
674
675             }
676         }
677     }]);
678
679     ngApp.directive('entityLinkSelector', [function($http) {
680         return {
681             restrict: 'A',
682             link: function(scope, element, attrs) {
683
684                 const selectButton = element.find('.entity-link-selector-confirm');
685                 let callback = false;
686                 let entitySelection = null;
687
688                 // Handle entity selection change, Stores the selected entity locally
689                 function entitySelectionChange(entity) {
690                     entitySelection = entity;
691                     if (entity === null) {
692                         selectButton.attr('disabled', 'true');
693                     } else {
694                         selectButton.removeAttr('disabled');
695                     }
696                 }
697                 events.listen('entity-select-change', entitySelectionChange);
698
699                 // Handle selection confirm button click
700                 selectButton.click(event => {
701                     hide();
702                     if (entitySelection !== null) callback(entitySelection);
703                 });
704
705                 // Show selector interface
706                 function show() {
707                     element.fadeIn(240);
708                 }
709
710                 // Hide selector interface
711                 function hide() {
712                     element.fadeOut(240);
713                 }
714
715                 // Listen to confirmation of entity selections (doubleclick)
716                 events.listen('entity-select-confirm', entity => {
717                     hide();
718                     callback(entity);
719                 });
720
721                 // Show entity selector, Accessible globally, and store the callback
722                 window.showEntityLinkSelector = function(passedCallback) {
723                     show();
724                     callback = passedCallback;
725                 };
726
727             }
728         };
729     }]);
730
731
732     ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
733         return {
734             restrict: 'A',
735             scope: true,
736             link: function (scope, element, attrs) {
737                 scope.loading = true;
738                 scope.entityResults = false;
739                 scope.search = '';
740
741                 // Add input for forms
742                 const input = element.find('[entity-selector-input]').first();
743
744                 // Detect double click events
745                 let lastClick = 0;
746                 function isDoubleClick() {
747                     let now = Date.now();
748                     let answer = now - lastClick < 300;
749                     lastClick = now;
750                     return answer;
751                 }
752
753                 // Listen to entity item clicks
754                 element.on('click', '.entity-list a', function(event) {
755                     event.preventDefault();
756                     event.stopPropagation();
757                     let item = $(this).closest('[data-entity-type]');
758                     itemSelect(item, isDoubleClick());
759                 });
760                 element.on('click', '[data-entity-type]', function(event) {
761                     itemSelect($(this), isDoubleClick());
762                 });
763
764                 // Select entity action
765                 function itemSelect(item, doubleClick) {
766                     let entityType = item.attr('data-entity-type');
767                     let entityId = item.attr('data-entity-id');
768                     let isSelected = !item.hasClass('selected') || doubleClick;
769                     element.find('.selected').removeClass('selected').removeClass('primary-background');
770                     if (isSelected) item.addClass('selected').addClass('primary-background');
771                     let newVal = isSelected ? `${entityType}:${entityId}` : '';
772                     input.val(newVal);
773
774                     if (!isSelected) {
775                         events.emit('entity-select-change', null);
776                     }
777
778                     if (!doubleClick && !isSelected) return;
779
780                     let link = item.find('.entity-list-item-link').attr('href');
781                     let name = item.find('.entity-list-item-name').text();
782
783                     if (doubleClick) {
784                         events.emit('entity-select-confirm', {
785                             id: Number(entityId),
786                             name: name,
787                             link: link
788                         });
789                     }
790
791                     if (isSelected) {
792                         events.emit('entity-select-change', {
793                             id: Number(entityId),
794                             name: name,
795                             link: link
796                         });
797                     }
798                 }
799
800                 // Get search url with correct types
801                 function getSearchUrl() {
802                     let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
803                     return window.baseUrl(`/ajax/search/entities?types=${types}`);
804                 }
805
806                 // Get initial contents
807                 $http.get(getSearchUrl()).then(resp => {
808                     scope.entityResults = $sce.trustAsHtml(resp.data);
809                     scope.loading = false;
810                 });
811
812                 // Search when typing
813                 scope.searchEntities = function() {
814                     scope.loading = true;
815                     input.val('');
816                     let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
817                     $http.get(url).then(resp => {
818                         scope.entityResults = $sce.trustAsHtml(resp.data);
819                         scope.loading = false;
820                     });
821                 };
822             }
823         };
824     }]);
825
826     ngApp.directive('commentReply', [function () {
827         return {
828             restrict: 'E',
829             templateUrl: 'comment-reply.html',
830             scope: {
831               pageId: '=',
832               parentId: '=',
833               parent: '='
834             },
835             link: function (scope, element) {
836                 scope.isReply = true;
837                 element.find('textarea').focus();
838                 scope.$on('evt.comment-success', function (event) {
839                     // no need for the event to do anything more.
840                     event.stopPropagation();
841                     event.preventDefault();
842                     element.remove();
843                     scope.$destroy();
844                 });
845             }
846         }
847     }]);
848
849     ngApp.directive('commentEdit', [function () {
850          return {
851             restrict: 'E',
852             templateUrl: 'comment-reply.html',
853             scope: {
854               comment: '=',
855             },
856             link: function (scope, element) {
857                 scope.isEdit = true;
858                 element.find('textarea').focus();
859                 scope.$on('evt.comment-success', function (event, commentId) {
860                    // no need for the event to do anything more.
861                    event.stopPropagation();
862                    event.preventDefault();
863                    if (commentId === scope.comment.id && !scope.isNew) {
864                        element.remove();
865                        scope.$destroy();
866                    }
867                 });
868             }
869         }
870     }]);
871
872
873     ngApp.directive('commentReplyLink', ['$document', '$compile', '$http', function ($document, $compile, $http) {
874         return {
875             scope: {
876                 comment: '='
877             },
878             link: function (scope, element, attr) {
879                 element.on('$destroy', function () {
880                     element.off('click');
881                     scope.$destroy();
882                 });
883
884                 element.on('click', function () {
885                     var $container = element.parents('.comment-box').first();
886                     if (!$container.length) {
887                         console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
888                         return;
889                     }
890                     if (attr.noCommentReplyDupe) {
891                         removeDupe();
892                     }
893
894                     compileHtml($container, scope, attr.isReply === 'true');
895                 });
896             }
897         };
898
899         function compileHtml($container, scope, isReply) {
900             let lnkFunc = null;
901             if (isReply) {
902                 lnkFunc = $compile('<comment-reply page-id="comment.pageId" parent-id="comment.id" parent="comment"></comment-reply>');
903             } else {
904                 lnkFunc = $compile('<comment-edit comment="comment"></comment-add>');
905             }
906             var compiledHTML = lnkFunc(scope);
907             $container.append(compiledHTML);
908         }
909
910         function removeDupe() {
911             let $existingElement = $document.find('.comments-list comment-reply, .comments-list comment-edit');
912             if (!$existingElement.length) {
913                 return;
914             }
915
916             $existingElement.remove();
917         }
918     }]);
919 };