]> BookStack Code Mirror - bookstack/blob - resources/assets/js/directives.js
Merge branch 'Abijeet-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 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
127                 function hide() {
128                     menu.hide();
129                     menu.removeClass('anim menuIn');
130                 }
131
132                 function show() {
133                     menu.show().addClass('anim menuIn');
134                     element.mouseleave(hide);
135
136                     // Focus on input if exist in dropdown and hide on enter press
137                     let inputs = menu.find('input');
138                     if (inputs.length > 0) inputs.first().focus();
139                 }
140
141                 // Hide menu on option click
142                 element.on('click', '> ul a', hide);
143                 // Show dropdown on toggle click.
144                 element.find('[dropdown-toggle]').on('click', show);
145                 // Hide menu on enter press in inputs
146                 element.on('keypress', 'input', event => {
147                     if (event.keyCode !== 13) return true;
148                     event.preventDefault();
149                     hide();
150                     return false;
151                 });
152             }
153         };
154     }]);
155
156     /**
157      * TinyMCE
158      * An angular wrapper around the tinyMCE editor.
159      */
160     ngApp.directive('tinymce', ['$timeout', function ($timeout) {
161         return {
162             restrict: 'A',
163             scope: {
164                 tinymce: '=',
165                 mceModel: '=',
166                 mceChange: '='
167             },
168             link: function (scope, element, attrs) {
169
170                 function tinyMceSetup(editor) {
171                     editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
172                         let content = editor.getContent();
173                         $timeout(() => {
174                             scope.mceModel = content;
175                         });
176                         scope.mceChange(content);
177                     });
178
179                     editor.on('keydown', (event) => {
180                         scope.$emit('editor-keydown', event);
181                     });
182
183                     editor.on('init', (e) => {
184                         scope.mceModel = editor.getContent();
185                     });
186
187                     scope.$on('html-update', (event, value) => {
188                         editor.setContent(value);
189                         editor.selection.select(editor.getBody(), true);
190                         editor.selection.collapse(false);
191                         scope.mceModel = editor.getContent();
192                     });
193                 }
194
195                 scope.tinymce.extraSetups.push(tinyMceSetup);
196                 tinymce.init(scope.tinymce);
197             }
198         }
199     }]);
200
201     const md = new MarkdownIt({html: true});
202     md.use(mdTasksLists, {label: true});
203
204     /**
205      * Markdown input
206      * Handles the logic for just the editor input field.
207      */
208     ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
209         return {
210             restrict: 'A',
211             scope: {
212                 mdModel: '=',
213                 mdChange: '='
214             },
215             link: function (scope, element, attrs) {
216
217                 // Codemirror Setup
218                 element = element.find('textarea').first();
219                 let cm = code.markdownEditor(element[0]);
220
221                 // Custom key commands
222                 let metaKey = code.getMetaKey();
223                 const extraKeys = {};
224                 // Insert Image shortcut
225                 extraKeys[`${metaKey}-Alt-I`] = function(cm) {
226                     let selectedText = cm.getSelection();
227                     let newText = `![${selectedText}](http://)`;
228                     let cursorPos = cm.getCursor('from');
229                     cm.replaceSelection(newText);
230                     cm.setCursor(cursorPos.line, cursorPos.ch + newText.length -1);
231                 };
232                 // Save draft
233                 extraKeys[`${metaKey}-S`] = function(cm) {scope.$emit('save-draft');};
234                 // Show link selector
235                 extraKeys[`Shift-${metaKey}-K`] = function(cm) {showLinkSelector()};
236                 // Insert Link
237                 extraKeys[`${metaKey}-K`] = function(cm) {insertLink()};
238                 // FormatShortcuts
239                 extraKeys[`${metaKey}-1`] = function(cm) {replaceLineStart('##');};
240                 extraKeys[`${metaKey}-2`] = function(cm) {replaceLineStart('###');};
241                 extraKeys[`${metaKey}-3`] = function(cm) {replaceLineStart('####');};
242                 extraKeys[`${metaKey}-4`] = function(cm) {replaceLineStart('#####');};
243                 extraKeys[`${metaKey}-5`] = function(cm) {replaceLineStart('');};
244                 extraKeys[`${metaKey}-d`] = function(cm) {replaceLineStart('');};
245                 extraKeys[`${metaKey}-6`] = function(cm) {replaceLineStart('>');};
246                 extraKeys[`${metaKey}-q`] = function(cm) {replaceLineStart('>');};
247                 extraKeys[`${metaKey}-7`] = function(cm) {wrapSelection('\n```\n', '\n```');};
248                 extraKeys[`${metaKey}-8`] = function(cm) {wrapSelection('`', '`');};
249                 extraKeys[`Shift-${metaKey}-E`] = function(cm) {wrapSelection('`', '`');};
250                 extraKeys[`${metaKey}-9`] = function(cm) {wrapSelection('<p class="callout info">', '</div>');};
251                 cm.setOption('extraKeys', extraKeys);
252
253                 // Update data on content change
254                 cm.on('change', (instance, changeObj) => {
255                     update(instance);
256                 });
257
258                 // Handle scroll to sync display view
259                 cm.on('scroll', instance => {
260                     // Thanks to http://liuhao.im/english/2015/11/10/the-sync-scroll-of-markdown-editor-in-javascript.html
261                     let scroll = instance.getScrollInfo();
262                     let atEnd = scroll.top + scroll.clientHeight === scroll.height;
263                     if (atEnd) {
264                         scope.$emit('markdown-scroll', -1);
265                         return;
266                     }
267                     let lineNum = instance.lineAtHeight(scroll.top, 'local');
268                     let range = instance.getRange({line: 0, ch: null}, {line: lineNum, ch: null});
269                     let parser = new DOMParser();
270                     let doc = parser.parseFromString(md.render(range), 'text/html');
271                     let totalLines = doc.documentElement.querySelectorAll('body > *');
272                     scope.$emit('markdown-scroll', totalLines.length);
273                 });
274
275                 // Handle image paste
276                 cm.on('paste', (cm, event) => {
277                     if (!event.clipboardData || !event.clipboardData.items) return;
278                     for (let i = 0; i < event.clipboardData.items.length; i++) {
279                         uploadImage(event.clipboardData.items[i].getAsFile());
280                     }
281                 });
282
283                 // Handle images on drag-drop
284                 cm.on('drop', (cm, event) => {
285                     event.stopPropagation();
286                     event.preventDefault();
287                     let cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
288                     cm.setCursor(cursorPos);
289                     if (!event.dataTransfer || !event.dataTransfer.files) return;
290                     for (let i = 0; i < event.dataTransfer.files.length; i++) {
291                         uploadImage(event.dataTransfer.files[i]);
292                     }
293                 });
294
295                 // Helper to replace editor content
296                 function replaceContent(search, replace) {
297                     let text = cm.getValue();
298                     let cursor = cm.listSelections();
299                     cm.setValue(text.replace(search, replace));
300                     cm.setSelections(cursor);
301                 }
302
303                 // Helper to replace the start of the line
304                 function replaceLineStart(newStart) {
305                     let cursor = cm.getCursor();
306                     let lineContent = cm.getLine(cursor.line);
307                     let lineLen = lineContent.length;
308                     let lineStart = lineContent.split(' ')[0];
309
310                     // Remove symbol if already set
311                     if (lineStart === newStart) {
312                         lineContent = lineContent.replace(`${newStart} `, '');
313                         cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
314                         cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
315                         return;
316                     }
317
318                     let alreadySymbol = /^[#>`]/.test(lineStart);
319                     let posDif = 0;
320                     if (alreadySymbol) {
321                         posDif = newStart.length - lineStart.length;
322                         lineContent = lineContent.replace(lineStart, newStart).trim();
323                     } else if (newStart !== '') {
324                         posDif = newStart.length + 1;
325                         lineContent = newStart + ' ' + lineContent;
326                     }
327                     cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
328                     cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
329                 }
330
331                 function wrapLine(start, end) {
332                     let cursor = cm.getCursor();
333                     let lineContent = cm.getLine(cursor.line);
334                     let lineLen = lineContent.length;
335                     let newLineContent = lineContent;
336
337                     if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
338                         newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
339                     } else {
340                         newLineContent = `${start}${lineContent}${end}`;
341                     }
342
343                     cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
344                     cm.setCursor({line: cursor.line, ch: cursor.ch + (newLineContent.length - lineLen)});
345                 }
346
347                 function wrapSelection(start, end) {
348                     let selection = cm.getSelection();
349                     if (selection === '') return wrapLine(start, end);
350                     let newSelection = selection;
351                     let frontDiff = 0;
352                     let endDiff = 0;
353
354                     if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
355                         newSelection = selection.slice(start.length, selection.length - end.length);
356                         endDiff = -(end.length + start.length);
357                     } else {
358                         newSelection = `${start}${selection}${end}`;
359                         endDiff = start.length + end.length;
360                     }
361
362                     let selections = cm.listSelections()[0];
363                     cm.replaceSelection(newSelection);
364                     let headFirst = selections.head.ch <= selections.anchor.ch;
365                     selections.head.ch += headFirst ? frontDiff : endDiff;
366                     selections.anchor.ch += headFirst ? endDiff : frontDiff;
367                     cm.setSelections([selections]);
368                 }
369
370                 // Handle image upload and add image into markdown content
371                 function uploadImage(file) {
372                     if (file === null || file.type.indexOf('image') !== 0) return;
373                     let ext = 'png';
374
375                     if (file.name) {
376                         let fileNameMatches = file.name.match(/\.(.+)$/);
377                         if (fileNameMatches.length > 1) ext = fileNameMatches[1];
378                     }
379
380                     // Insert image into markdown
381                     let id = "image-" + Math.random().toString(16).slice(2);
382                     let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
383                     let selectedText = cm.getSelection();
384                     let placeHolderText = `![${selectedText}](${placeholderImage})`;
385                     cm.replaceSelection(placeHolderText);
386
387                     let remoteFilename = "image-" + Date.now() + "." + ext;
388                     let formData = new FormData();
389                     formData.append('file', file, remoteFilename);
390
391                     window.$http.post('/images/gallery/upload', formData).then(resp => {
392                         replaceContent(placeholderImage, resp.data.thumbs.display);
393                     }).catch(err => {
394                         events.emit('error', trans('errors.image_upload_error'));
395                         replaceContent(placeHolderText, selectedText);
396                         console.log(err);
397                     });
398                 }
399
400                 // Show the popup link selector and insert a link when finished
401                 function showLinkSelector() {
402                     let cursorPos = cm.getCursor('from');
403                     window.showEntityLinkSelector(entity => {
404                         let selectedText = cm.getSelection() || entity.name;
405                         let newText = `[${selectedText}](${entity.link})`;
406                         cm.focus();
407                         cm.replaceSelection(newText);
408                         cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
409                     });
410                 }
411
412                 function insertLink() {
413                     let cursorPos = cm.getCursor('from');
414                     let selectedText = cm.getSelection() || '';
415                     let newText = `[${selectedText}]()`;
416                     cm.focus();
417                     cm.replaceSelection(newText);
418                     let cursorPosDiff = (selectedText === '') ? -3 : -1;
419                     cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
420                 }
421
422                 // Show the image manager and handle image insertion
423                 function showImageManager() {
424                     let cursorPos = cm.getCursor('from');
425                     window.ImageManager.showExternal(image => {
426                         let selectedText = cm.getSelection();
427                         let newText = "![" + (selectedText || image.name) + "](" + image.thumbs.display + ")";
428                         cm.focus();
429                         cm.replaceSelection(newText);
430                         cm.setCursor(cursorPos.line, cursorPos.ch + newText.length);
431                     });
432                 }
433
434                 // Update the data models and rendered output
435                 function update(instance) {
436                     let content = instance.getValue();
437                     element.val(content);
438                     $timeout(() => {
439                         scope.mdModel = content;
440                         scope.mdChange(md.render(content));
441                     });
442                 }
443                 update(cm);
444
445                 // Listen to commands from parent scope
446                 scope.$on('md-insert-link', showLinkSelector);
447                 scope.$on('md-insert-image', showImageManager);
448                 scope.$on('markdown-update', (event, value) => {
449                     cm.setValue(value);
450                     element.val(value);
451                     scope.mdModel = value;
452                     scope.mdChange(md.render(value));
453                 });
454
455             }
456         }
457     }]);
458
459     /**
460      * Markdown Editor
461      * Handles all functionality of the markdown editor.
462      */
463     ngApp.directive('markdownEditor', ['$timeout', '$rootScope', function ($timeout, $rootScope) {
464         return {
465             restrict: 'A',
466             link: function (scope, element, attrs) {
467
468                 // Editor Elements
469                 const $display = element.find('.markdown-display').first();
470                 const $insertImage = element.find('button[data-action="insertImage"]');
471                 const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
472
473                 // Prevent markdown display link click redirect
474                 $display.on('click', 'a', function(event) {
475                     event.preventDefault();
476                     window.open(this.getAttribute('href'));
477                 });
478
479                 // Editor UI Actions
480                 $insertEntityLink.click(e => {scope.$broadcast('md-insert-link');});
481                 $insertImage.click(e => {scope.$broadcast('md-insert-image');});
482
483                 // Handle scroll sync event from editor scroll
484                 $rootScope.$on('markdown-scroll', (event, lineCount) => {
485                     let elems = $display[0].children[0].children;
486                     if (elems.length > lineCount) {
487                         let topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
488                         $display.animate({
489                             scrollTop: topElem.offsetTop
490                         }, {queue: false, duration: 200, easing: 'linear'});
491                     }
492                 });
493             }
494         }
495     }]);
496
497     /**
498      * Page Editor Toolbox
499      * Controls all functionality for the sliding toolbox
500      * on the page edit view.
501      */
502     ngApp.directive('toolbox', [function () {
503         return {
504             restrict: 'A',
505             link: function (scope, elem, attrs) {
506
507                 // Get common elements
508                 const $buttons = elem.find('[toolbox-tab-button]');
509                 const $content = elem.find('[toolbox-tab-content]');
510                 const $toggle = elem.find('[toolbox-toggle]');
511
512                 // Handle toolbox toggle click
513                 $toggle.click((e) => {
514                     elem.toggleClass('open');
515                 });
516
517                 // Set an active tab/content by name
518                 function setActive(tabName, openToolbox) {
519                     $buttons.removeClass('active');
520                     $content.hide();
521                     $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
522                     $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
523                     if (openToolbox) elem.addClass('open');
524                 }
525
526                 // Set the first tab content active on load
527                 setActive($content.first().attr('toolbox-tab-content'), false);
528
529                 // Handle tab button click
530                 $buttons.click(function (e) {
531                     let name = $(this).attr('toolbox-tab-button');
532                     setActive(name, true);
533                 });
534             }
535         }
536     }]);
537
538     /**
539      * Tag Autosuggestions
540      * Listens to child inputs and provides autosuggestions depending on field type
541      * and input. Suggestions provided by server.
542      */
543     ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
544         return {
545             restrict: 'A',
546             link: function (scope, elem, attrs) {
547
548                 // Local storage for quick caching.
549                 const localCache = {};
550
551                 // Create suggestion element
552                 const suggestionBox = document.createElement('ul');
553                 suggestionBox.className = 'suggestion-box';
554                 suggestionBox.style.position = 'absolute';
555                 suggestionBox.style.display = 'none';
556                 const $suggestionBox = $(suggestionBox);
557
558                 // General state tracking
559                 let isShowing = false;
560                 let currentInput = false;
561                 let active = 0;
562
563                 // Listen to input events on autosuggest fields
564                 elem.on('input focus', '[autosuggest]', function (event) {
565                     let $input = $(this);
566                     let val = $input.val();
567                     let url = $input.attr('autosuggest');
568                     let type = $input.attr('autosuggest-type');
569
570                     // Add name param to request if for a value
571                     if (type.toLowerCase() === 'value') {
572                         let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
573                         let nameVal = $nameInput.val();
574                         if (nameVal !== '') {
575                             url += '?name=' + encodeURIComponent(nameVal);
576                         }
577                     }
578
579                     let suggestionPromise = getSuggestions(val.slice(0, 3), url);
580                     suggestionPromise.then(suggestions => {
581                         if (val.length === 0) {
582                             displaySuggestions($input, suggestions.slice(0, 6));
583                         } else  {
584                             suggestions = suggestions.filter(item => {
585                                 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
586                             }).slice(0, 4);
587                             displaySuggestions($input, suggestions);
588                         }
589                     });
590                 });
591
592                 // Hide autosuggestions when input loses focus.
593                 // Slight delay to allow clicks.
594                 let lastFocusTime = 0;
595                 elem.on('blur', '[autosuggest]', function (event) {
596                     let startTime = Date.now();
597                     setTimeout(() => {
598                         if (lastFocusTime < startTime) {
599                             $suggestionBox.hide();
600                             isShowing = false;
601                         }
602                     }, 200)
603                 });
604                 elem.on('focus', '[autosuggest]', function (event) {
605                     lastFocusTime = Date.now();
606                 });
607
608                 elem.on('keydown', '[autosuggest]', function (event) {
609                     if (!isShowing) return;
610
611                     let suggestionElems = suggestionBox.childNodes;
612                     let suggestCount = suggestionElems.length;
613
614                     // Down arrow
615                     if (event.keyCode === 40) {
616                         let newActive = (active === suggestCount - 1) ? 0 : active + 1;
617                         changeActiveTo(newActive, suggestionElems);
618                     }
619                     // Up arrow
620                     else if (event.keyCode === 38) {
621                         let newActive = (active === 0) ? suggestCount - 1 : active - 1;
622                         changeActiveTo(newActive, suggestionElems);
623                     }
624                     // Enter or tab key
625                     else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
626                         currentInput[0].value = suggestionElems[active].textContent;
627                         currentInput.focus();
628                         $suggestionBox.hide();
629                         isShowing = false;
630                         if (event.keyCode === 13) {
631                             event.preventDefault();
632                             return false;
633                         }
634                     }
635                 });
636
637                 // Change the active suggestion to the given index
638                 function changeActiveTo(index, suggestionElems) {
639                     suggestionElems[active].className = '';
640                     active = index;
641                     suggestionElems[active].className = 'active';
642                 }
643
644                 // Display suggestions on a field
645                 let prevSuggestions = [];
646
647                 function displaySuggestions($input, suggestions) {
648
649                     // Hide if no suggestions
650                     if (suggestions.length === 0) {
651                         $suggestionBox.hide();
652                         isShowing = false;
653                         prevSuggestions = suggestions;
654                         return;
655                     }
656
657                     // Otherwise show and attach to input
658                     if (!isShowing) {
659                         $suggestionBox.show();
660                         isShowing = true;
661                     }
662                     if ($input !== currentInput) {
663                         $suggestionBox.detach();
664                         $input.after($suggestionBox);
665                         currentInput = $input;
666                     }
667
668                     // Return if no change
669                     if (prevSuggestions.join() === suggestions.join()) {
670                         prevSuggestions = suggestions;
671                         return;
672                     }
673
674                     // Build suggestions
675                     $suggestionBox[0].innerHTML = '';
676                     for (let i = 0; i < suggestions.length; i++) {
677                         let suggestion = document.createElement('li');
678                         suggestion.textContent = suggestions[i];
679                         suggestion.onclick = suggestionClick;
680                         if (i === 0) {
681                             suggestion.className = 'active';
682                             active = 0;
683                         }
684                         $suggestionBox[0].appendChild(suggestion);
685                     }
686
687                     prevSuggestions = suggestions;
688                 }
689
690                 // Suggestion click event
691                 function suggestionClick(event) {
692                     currentInput[0].value = this.textContent;
693                     currentInput.focus();
694                     $suggestionBox.hide();
695                     isShowing = false;
696                 }
697
698                 // Get suggestions & cache
699                 function getSuggestions(input, url) {
700                     let hasQuery = url.indexOf('?') !== -1;
701                     let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
702
703                     // Get from local cache if exists
704                     if (typeof localCache[searchUrl] !== 'undefined') {
705                         return new Promise((resolve, reject) => {
706                             resolve(localCache[searchUrl]);
707                         });
708                     }
709
710                     return $http.get(searchUrl).then(response => {
711                         localCache[searchUrl] = response.data;
712                         return response.data;
713                     });
714                 }
715
716             }
717         }
718     }]);
719
720     ngApp.directive('entityLinkSelector', [function($http) {
721         return {
722             restrict: 'A',
723             link: function(scope, element, attrs) {
724
725                 const selectButton = element.find('.entity-link-selector-confirm');
726                 let callback = false;
727                 let entitySelection = null;
728
729                 // Handle entity selection change, Stores the selected entity locally
730                 function entitySelectionChange(entity) {
731                     entitySelection = entity;
732                     if (entity === null) {
733                         selectButton.attr('disabled', 'true');
734                     } else {
735                         selectButton.removeAttr('disabled');
736                     }
737                 }
738                 events.listen('entity-select-change', entitySelectionChange);
739
740                 // Handle selection confirm button click
741                 selectButton.click(event => {
742                     hide();
743                     if (entitySelection !== null) callback(entitySelection);
744                 });
745
746                 // Show selector interface
747                 function show() {
748                     element.fadeIn(240);
749                 }
750
751                 // Hide selector interface
752                 function hide() {
753                     element.fadeOut(240);
754                 }
755
756                 // Listen to confirmation of entity selections (doubleclick)
757                 events.listen('entity-select-confirm', entity => {
758                     hide();
759                     callback(entity);
760                 });
761
762                 // Show entity selector, Accessible globally, and store the callback
763                 window.showEntityLinkSelector = function(passedCallback) {
764                     show();
765                     callback = passedCallback;
766                 };
767
768             }
769         };
770     }]);
771
772
773     ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
774         return {
775             restrict: 'A',
776             scope: true,
777             link: function (scope, element, attrs) {
778                 scope.loading = true;
779                 scope.entityResults = false;
780                 scope.search = '';
781
782                 // Add input for forms
783                 const input = element.find('[entity-selector-input]').first();
784
785                 // Detect double click events
786                 let lastClick = 0;
787                 function isDoubleClick() {
788                     let now = Date.now();
789                     let answer = now - lastClick < 300;
790                     lastClick = now;
791                     return answer;
792                 }
793
794                 // Listen to entity item clicks
795                 element.on('click', '.entity-list a', function(event) {
796                     event.preventDefault();
797                     event.stopPropagation();
798                     let item = $(this).closest('[data-entity-type]');
799                     itemSelect(item, isDoubleClick());
800                 });
801                 element.on('click', '[data-entity-type]', function(event) {
802                     itemSelect($(this), isDoubleClick());
803                 });
804
805                 // Select entity action
806                 function itemSelect(item, doubleClick) {
807                     let entityType = item.attr('data-entity-type');
808                     let entityId = item.attr('data-entity-id');
809                     let isSelected = !item.hasClass('selected') || doubleClick;
810                     element.find('.selected').removeClass('selected').removeClass('primary-background');
811                     if (isSelected) item.addClass('selected').addClass('primary-background');
812                     let newVal = isSelected ? `${entityType}:${entityId}` : '';
813                     input.val(newVal);
814
815                     if (!isSelected) {
816                         events.emit('entity-select-change', null);
817                     }
818
819                     if (!doubleClick && !isSelected) return;
820
821                     let link = item.find('.entity-list-item-link').attr('href');
822                     let name = item.find('.entity-list-item-name').text();
823
824                     if (doubleClick) {
825                         events.emit('entity-select-confirm', {
826                             id: Number(entityId),
827                             name: name,
828                             link: link
829                         });
830                     }
831
832                     if (isSelected) {
833                         events.emit('entity-select-change', {
834                             id: Number(entityId),
835                             name: name,
836                             link: link
837                         });
838                     }
839                 }
840
841                 // Get search url with correct types
842                 function getSearchUrl() {
843                     let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
844                     return window.baseUrl(`/ajax/search/entities?types=${types}`);
845                 }
846
847                 // Get initial contents
848                 $http.get(getSearchUrl()).then(resp => {
849                     scope.entityResults = $sce.trustAsHtml(resp.data);
850                     scope.loading = false;
851                 });
852
853                 // Search when typing
854                 scope.searchEntities = function() {
855                     scope.loading = true;
856                     input.val('');
857                     let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
858                     $http.get(url).then(resp => {
859                         scope.entityResults = $sce.trustAsHtml(resp.data);
860                         scope.loading = false;
861                     });
862                 };
863             }
864         };
865     }]);
866
867     ngApp.directive('commentReply', [function () {
868         return {
869             restrict: 'E',
870             templateUrl: 'comment-reply.html',
871             scope: {
872               pageId: '=',
873               parentId: '=',
874               parent: '='
875             },
876             link: function (scope, element) {
877                 scope.isReply = true;
878                 element.find('textarea').focus();
879                 scope.$on('evt.comment-success', function (event) {
880                     // no need for the event to do anything more.
881                     event.stopPropagation();
882                     event.preventDefault();
883                     scope.closeBox();
884                 });
885
886                 scope.closeBox = function () {
887                     element.remove();
888                     scope.$destroy();
889                 };
890             }
891         };
892     }]);
893
894     ngApp.directive('commentEdit', [function () {
895          return {
896             restrict: 'E',
897             templateUrl: 'comment-reply.html',
898             scope: {
899               comment: '='
900             },
901             link: function (scope, element) {
902                 scope.isEdit = true;
903                 element.find('textarea').focus();
904                 scope.$on('evt.comment-success', function (event, commentId) {
905                    // no need for the event to do anything more.
906                    event.stopPropagation();
907                    event.preventDefault();
908                    if (commentId === scope.comment.id && !scope.isNew) {
909                        scope.closeBox();
910                    }
911                 });
912
913                 scope.closeBox = function () {
914                     element.remove();
915                     scope.$destroy();
916                 };
917             }
918         };
919     }]);
920
921
922     ngApp.directive('commentReplyLink', ['$document', '$compile', function ($document, $compile) {
923         return {
924             scope: {
925                 comment: '='
926             },
927             link: function (scope, element, attr) {
928                 element.on('$destroy', function () {
929                     element.off('click');
930                     scope.$destroy();
931                 });
932
933                 element.on('click', function (e) {
934                     e.preventDefault();
935                     var $container = element.parents('.comment-actions').first();
936                     if (!$container.length) {
937                         console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
938                         return;
939                     }
940                     if (attr.noCommentReplyDupe) {
941                         removeDupe();
942                     }
943
944                     compileHtml($container, scope, attr.isReply === 'true');
945                 });
946             }
947         };
948
949         function compileHtml($container, scope, isReply) {
950             let lnkFunc = null;
951             if (isReply) {
952                 lnkFunc = $compile('<comment-reply page-id="comment.pageId" parent-id="comment.id" parent="comment"></comment-reply>');
953             } else {
954                 lnkFunc = $compile('<comment-edit comment="comment"></comment-add>');
955             }
956             var compiledHTML = lnkFunc(scope);
957             $container.append(compiledHTML);
958         }
959
960         function removeDupe() {
961             let $existingElement = $document.find('.comments-list comment-reply, .comments-list comment-edit');
962             if (!$existingElement.length) {
963                 return;
964             }
965
966             $existingElement.remove();
967         }
968     }]);
969
970     ngApp.directive('commentDeleteLink', ['$window', function ($window) {
971         return {
972             controller: 'CommentDeleteController',
973             scope: {
974                 comment: '='
975             },
976             link: function (scope, element, attr, ctrl) {
977
978                 element.on('click', function(e) {
979                     e.preventDefault();
980                     var resp = $window.confirm(trans('entities.comment_delete_confirm'));
981                     if (!resp) {
982                         return;
983                     }
984
985                     ctrl.delete(scope.comment);
986                 });
987             }
988         };
989     }]);
990 };