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