2 const DropZone = require('dropzone');
3 const markdown = require('marked');
5 module.exports = function (ngApp, events) {
9 * Has basic on/off functionality.
10 * Use string values of 'true' & 'false' to dictate the current state.
12 ngApp.directive('toggleSwitch', function () {
16 <div class="toggle-switch" ng-click="switch()" ng-class="{'active': isActive}">
17 <input type="hidden" ng-attr-name="{{name}}" ng-attr-value="{{value}}"/>
18 <div class="switch-handle"></div>
22 link: function (scope, element, attrs) {
23 scope.name = attrs.name;
24 scope.value = attrs.value;
25 scope.isActive = scope.value == true && scope.value != 'false';
26 scope.value = (scope.value == true && scope.value != 'false') ? 'true' : 'false';
28 scope.switch = function () {
29 scope.isActive = !scope.isActive;
30 scope.value = scope.isActive ? 'true' : 'false';
38 * Common tab controls using simple jQuery functions.
40 ngApp.directive('tabContainer', function() {
43 link: function (scope, element, attrs) {
44 const $content = element.find('[tab-content]');
45 const $buttons = element.find('[tab-button]');
47 if (attrs.tabContainer) {
48 let initial = attrs.tabContainer;
49 $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
50 $content.hide().filter(`[tab-content="${initial}"]`).show();
52 $content.hide().first().show();
53 $buttons.first().addClass('selected');
56 $buttons.click(function() {
57 let clickedTab = $(this);
58 $buttons.removeClass('selected');
60 let name = clickedTab.addClass('selected').attr('tab-button');
61 $content.filter(`[tab-content="${name}"]`).show();
68 * Sub form component to allow inner-form sections to act like thier own forms.
70 ngApp.directive('subForm', function() {
73 link: function (scope, element, attrs) {
74 element.on('keypress', e => {
75 if (e.keyCode === 13) {
80 element.find('button[type="submit"]').click(submitEvent);
82 function submitEvent(e) {
84 if (attrs.subForm) scope.$eval(attrs.subForm);
93 * Is a simple front-end interface that connects to an ImageManager if present.
95 ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
99 <div class="image-picker">
101 <img ng-if="image && image !== 'none'" ng-src="{{image}}" ng-class="{{imageClass}}" alt="Image Preview">
102 <img ng-if="image === '' && defaultImage" ng-src="{{defaultImage}}" ng-class="{{imageClass}}" alt="Image Preview">
104 <button class="button" type="button" ng-click="showImageManager()">Select Image</button>
107 <button class="text-button" ng-click="reset()" type="button">Reset</button>
108 <span ng-show="showRemove" class="sep">|</span>
109 <button ng-show="showRemove" class="text-button neg" ng-click="remove()" type="button">Remove</button>
111 <input type="hidden" ng-attr-name="{{name}}" ng-attr-id="{{name}}" ng-attr-value="{{value}}">
125 link: function (scope, element, attrs) {
126 let usingIds = typeof scope.currentId !== 'undefined' || scope.currentId === 'false';
127 scope.image = scope.currentImage;
128 scope.value = scope.currentImage || '';
129 if (usingIds) scope.value = scope.currentId;
131 function setImage(imageModel, imageUrl) {
132 scope.image = imageUrl;
133 scope.value = usingIds ? imageModel.id : imageUrl;
136 scope.reset = function () {
137 setImage({id: 0}, scope.defaultImage);
140 scope.remove = function () {
141 scope.image = 'none';
142 scope.value = 'none';
145 scope.showImageManager = function () {
146 imageManagerService.show((image) => {
147 scope.updateImageFromModel(image);
151 scope.updateImageFromModel = function (model) {
152 let isResized = scope.resizeWidth && scope.resizeHeight;
156 setImage(model, model.url);
161 let cropped = scope.resizeCrop ? 'true' : 'false';
162 let requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
163 requestString = window.baseUrl(requestString);
164 $http.get(requestString).then((response) => {
165 setImage(model, response.data.url);
175 * Used for uploading images
177 ngApp.directive('dropZone', [function () {
181 <div class="dropzone-container">
182 <div class="dz-message">Drop files or click here to upload</div>
191 link: function (scope, element, attrs) {
192 if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
193 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
194 url: scope.uploadUrl,
197 dz.on('sending', function (file, xhr, data) {
198 var token = window.document.querySelector('meta[name=token]').getAttribute('content');
199 data.append('_token', token);
200 var uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
201 data.append('uploaded_to', uploadedTo);
203 if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
204 dz.on('success', function (file, data) {
205 $(file.previewElement).fadeOut(400, function () {
209 if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
210 dz.on('error', function (file, errorMessage, xhr) {
211 console.log(errorMessage);
213 function setMessage(message) {
214 $(file.previewElement).find('[data-dz-errormessage]').text(message);
217 if (xhr.status === 413) setMessage('The server does not allow uploads of this size. Please try a smaller file.');
218 if (errorMessage.file) setMessage(errorMessage.file[0]);
229 * Provides some simple logic to create small dropdown menus
231 ngApp.directive('dropdown', [function () {
234 link: function (scope, element, attrs) {
235 const menu = element.find('ul');
236 element.find('[dropdown-toggle]').on('click', function () {
237 menu.show().addClass('anim menuIn');
238 let inputs = menu.find('input');
239 let hasInput = inputs.length > 0;
241 inputs.first().focus();
242 element.on('keypress', 'input', event => {
243 if (event.keyCode === 13) {
244 event.preventDefault();
246 menu.removeClass('anim menuIn');
251 element.mouseleave(function () {
253 menu.removeClass('anim menuIn');
262 * An angular wrapper around the tinyMCE editor.
264 ngApp.directive('tinymce', ['$timeout', function ($timeout) {
272 link: function (scope, element, attrs) {
274 function tinyMceSetup(editor) {
275 editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
276 var content = editor.getContent();
278 scope.mceModel = content;
280 scope.mceChange(content);
283 editor.on('keydown', (event) => {
284 scope.$emit('editor-keydown', event);
287 editor.on('init', (e) => {
288 scope.mceModel = editor.getContent();
291 scope.$on('html-update', (event, value) => {
292 editor.setContent(value);
293 editor.selection.select(editor.getBody(), true);
294 editor.selection.collapse(false);
295 scope.mceModel = editor.getContent();
299 scope.tinymce.extraSetups.push(tinyMceSetup);
301 // Custom tinyMCE plugins
302 tinymce.PluginManager.add('customhr', function (editor) {
303 editor.addCommand('InsertHorizontalRule', function () {
304 var hrElem = document.createElement('hr');
305 var cNode = editor.selection.getNode();
306 var parentNode = cNode.parentNode;
307 parentNode.insertBefore(hrElem, cNode);
310 editor.addButton('hr', {
312 tooltip: 'Horizontal line',
313 cmd: 'InsertHorizontalRule'
316 editor.addMenuItem('hr', {
318 text: 'Horizontal line',
319 cmd: 'InsertHorizontalRule',
324 tinymce.init(scope.tinymce);
331 * Handles the logic for just the editor input field.
333 ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
340 link: function (scope, element, attrs) {
342 // Set initial model content
343 element = element.find('textarea').first();
344 let content = element.val();
345 scope.mdModel = content;
346 scope.mdChange(markdown(content));
348 element.on('change input', (event) => {
349 content = element.val();
351 scope.mdModel = content;
352 scope.mdChange(markdown(content));
356 scope.$on('markdown-update', (event, value) => {
358 scope.mdModel = value;
359 scope.mdChange(markdown(value));
368 * Handles all functionality of the markdown editor.
370 ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
373 link: function (scope, element, attrs) {
376 const input = element.find('[markdown-input] textarea').first();
377 const display = element.find('.markdown-display').first();
378 const insertImage = element.find('button[data-action="insertImage"]');
379 const insertEntityLink = element.find('button[data-action="insertEntityLink"]')
381 let currentCaretPos = 0;
383 input.blur(event => {
384 currentCaretPos = input[0].selectionStart;
388 let inputScrollHeight,
393 function setScrollHeights() {
394 inputScrollHeight = input[0].scrollHeight;
395 inputHeight = input.height();
396 displayScrollHeight = display[0].scrollHeight;
397 displayHeight = display.height();
403 window.addEventListener('resize', setScrollHeights);
404 let scrollDebounceTime = 800;
406 input.on('scroll', event => {
407 let now = Date.now();
408 if (now - lastScroll > scrollDebounceTime) {
411 let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
412 let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
413 display.scrollTop(displayScrollY);
417 // Editor key-presses
418 input.keydown(event => {
419 // Insert image shortcut
420 if (event.which === 73 && event.ctrlKey && event.shiftKey) {
421 event.preventDefault();
422 let caretPos = input[0].selectionStart;
423 let currentContent = input.val();
424 const mdImageText = "";
425 input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
427 input[0].selectionStart = caretPos + (";
428 input[0].selectionEnd = caretPos + (';
432 // Insert entity link shortcut
433 if (event.which === 75 && event.ctrlKey && event.shiftKey) {
438 // Pass key presses to controller via event
439 scope.$emit('editor-keydown', event);
442 // Insert image from image manager
443 insertImage.click(event => {
444 window.ImageManager.showExternal(image => {
445 let caretPos = currentCaretPos;
446 let currentContent = input.val();
447 let mdImageText = "";
448 input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
453 function showLinkSelector() {
454 window.showEntityLinkSelector((entity) => {
455 let selectionStart = currentCaretPos;
456 let selectionEnd = input[0].selectionEnd;
457 let textSelected = (selectionEnd !== selectionStart);
458 let currentContent = input.val();
461 let selectedText = currentContent.substring(selectionStart, selectionEnd);
462 let linkText = `[${selectedText}](${entity.link})`;
463 input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
465 let linkText = ` [${entity.name}](${entity.link}) `;
466 input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
471 insertEntityLink.click(showLinkSelector);
473 // Upload and insert image on paste
474 function editorPaste(e) {
476 if (!e.clipboardData) return
477 var items = e.clipboardData.items;
479 for (var i = 0; i < items.length; i++) {
480 uploadImage(items[i].getAsFile());
484 input.on('paste', editorPaste);
486 // Handle image drop, Uploads images to BookStack.
487 function handleImageDrop(event) {
488 event.stopPropagation();
489 event.preventDefault();
490 let files = event.originalEvent.dataTransfer.files;
491 for (let i = 0; i < files.length; i++) {
492 uploadImage(files[i]);
496 input.on('drop', handleImageDrop);
498 // Handle image upload and add image into markdown content
499 function uploadImage(file) {
500 if (file.type.indexOf('image') !== 0) return;
501 var formData = new FormData();
503 var xhr = new XMLHttpRequest();
506 var fileNameMatches = file.name.match(/\.(.+)$/);
507 if (fileNameMatches) {
508 ext = fileNameMatches[1];
512 // Insert image into markdown
513 let id = "image-" + Math.random().toString(16).slice(2);
514 let selectStart = input[0].selectionStart;
515 let selectEnd = input[0].selectionEnd;
516 let content = input[0].value;
517 let selectText = content.substring(selectStart, selectEnd);
518 let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
519 let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
520 input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd);
523 input[0].selectionStart = selectStart;
524 input[0].selectionEnd = selectStart;
526 let remoteFilename = "image-" + Date.now() + "." + ext;
527 formData.append('file', file, remoteFilename);
528 formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
530 xhr.open('POST', window.baseUrl('/images/gallery/upload'));
531 xhr.onload = function () {
532 let selectStart = input[0].selectionStart;
533 if (xhr.status === 200 || xhr.status === 201) {
534 var result = JSON.parse(xhr.responseText);
535 input[0].value = input[0].value.replace(placeholderImage, result.thumbs.display);
538 console.log('An error occurred uploading the image');
539 console.log(xhr.responseText);
540 input[0].value = input[0].value.replace(innerContent, '');
544 input[0].selectionStart = selectStart;
545 input[0].selectionEnd = selectStart;
555 * Page Editor Toolbox
556 * Controls all functionality for the sliding toolbox
557 * on the page edit view.
559 ngApp.directive('toolbox', [function () {
562 link: function (scope, elem, attrs) {
564 // Get common elements
565 const $buttons = elem.find('[toolbox-tab-button]');
566 const $content = elem.find('[toolbox-tab-content]');
567 const $toggle = elem.find('[toolbox-toggle]');
569 // Handle toolbox toggle click
570 $toggle.click((e) => {
571 elem.toggleClass('open');
574 // Set an active tab/content by name
575 function setActive(tabName, openToolbox) {
576 $buttons.removeClass('active');
578 $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
579 $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
580 if (openToolbox) elem.addClass('open');
583 // Set the first tab content active on load
584 setActive($content.first().attr('toolbox-tab-content'), false);
586 // Handle tab button click
587 $buttons.click(function (e) {
588 let name = $(this).attr('toolbox-tab-button');
589 setActive(name, true);
596 * Tag Autosuggestions
597 * Listens to child inputs and provides autosuggestions depending on field type
598 * and input. Suggestions provided by server.
600 ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
603 link: function (scope, elem, attrs) {
605 // Local storage for quick caching.
606 const localCache = {};
608 // Create suggestion element
609 const suggestionBox = document.createElement('ul');
610 suggestionBox.className = 'suggestion-box';
611 suggestionBox.style.position = 'absolute';
612 suggestionBox.style.display = 'none';
613 const $suggestionBox = $(suggestionBox);
615 // General state tracking
616 let isShowing = false;
617 let currentInput = false;
620 // Listen to input events on autosuggest fields
621 elem.on('input focus', '[autosuggest]', function (event) {
622 let $input = $(this);
623 let val = $input.val();
624 let url = $input.attr('autosuggest');
625 let type = $input.attr('autosuggest-type');
627 // Add name param to request if for a value
628 if (type.toLowerCase() === 'value') {
629 let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
630 let nameVal = $nameInput.val();
631 if (nameVal !== '') {
632 url += '?name=' + encodeURIComponent(nameVal);
636 let suggestionPromise = getSuggestions(val.slice(0, 3), url);
637 suggestionPromise.then(suggestions => {
638 if (val.length === 0) {
639 displaySuggestions($input, suggestions.slice(0, 6));
641 suggestions = suggestions.filter(item => {
642 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
644 displaySuggestions($input, suggestions);
649 // Hide autosuggestions when input loses focus.
650 // Slight delay to allow clicks.
651 let lastFocusTime = 0;
652 elem.on('blur', '[autosuggest]', function (event) {
653 let startTime = Date.now();
655 if (lastFocusTime < startTime) {
656 $suggestionBox.hide();
661 elem.on('focus', '[autosuggest]', function (event) {
662 lastFocusTime = Date.now();
665 elem.on('keydown', '[autosuggest]', function (event) {
666 if (!isShowing) return;
668 let suggestionElems = suggestionBox.childNodes;
669 let suggestCount = suggestionElems.length;
672 if (event.keyCode === 40) {
673 let newActive = (active === suggestCount - 1) ? 0 : active + 1;
674 changeActiveTo(newActive, suggestionElems);
677 else if (event.keyCode === 38) {
678 let newActive = (active === 0) ? suggestCount - 1 : active - 1;
679 changeActiveTo(newActive, suggestionElems);
682 else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
683 let text = suggestionElems[active].textContent;
684 currentInput[0].value = text;
685 currentInput.focus();
686 $suggestionBox.hide();
688 if (event.keyCode === 13) {
689 event.preventDefault();
695 // Change the active suggestion to the given index
696 function changeActiveTo(index, suggestionElems) {
697 suggestionElems[active].className = '';
699 suggestionElems[active].className = 'active';
702 // Display suggestions on a field
703 let prevSuggestions = [];
705 function displaySuggestions($input, suggestions) {
707 // Hide if no suggestions
708 if (suggestions.length === 0) {
709 $suggestionBox.hide();
711 prevSuggestions = suggestions;
715 // Otherwise show and attach to input
717 $suggestionBox.show();
720 if ($input !== currentInput) {
721 $suggestionBox.detach();
722 $input.after($suggestionBox);
723 currentInput = $input;
726 // Return if no change
727 if (prevSuggestions.join() === suggestions.join()) {
728 prevSuggestions = suggestions;
733 $suggestionBox[0].innerHTML = '';
734 for (let i = 0; i < suggestions.length; i++) {
735 var suggestion = document.createElement('li');
736 suggestion.textContent = suggestions[i];
737 suggestion.onclick = suggestionClick;
739 suggestion.className = 'active'
743 $suggestionBox[0].appendChild(suggestion);
746 prevSuggestions = suggestions;
749 // Suggestion click event
750 function suggestionClick(event) {
751 let text = this.textContent;
752 currentInput[0].value = text;
753 currentInput.focus();
754 $suggestionBox.hide();
758 // Get suggestions & cache
759 function getSuggestions(input, url) {
760 let hasQuery = url.indexOf('?') !== -1;
761 let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
763 // Get from local cache if exists
764 if (typeof localCache[searchUrl] !== 'undefined') {
765 return new Promise((resolve, reject) => {
766 resolve(localCache[searchUrl]);
770 return $http.get(searchUrl).then(response => {
771 localCache[searchUrl] = response.data;
772 return response.data;
780 ngApp.directive('entityLinkSelector', [function($http) {
783 link: function(scope, element, attrs) {
785 const selectButton = element.find('.entity-link-selector-confirm');
786 let callback = false;
787 let entitySelection = null;
789 // Handle entity selection change, Stores the selected entity locally
790 function entitySelectionChange(entity) {
791 entitySelection = entity;
792 if (entity === null) {
793 selectButton.attr('disabled', 'true');
795 selectButton.removeAttr('disabled');
798 events.listen('entity-select-change', entitySelectionChange);
800 // Handle selection confirm button click
801 selectButton.click(event => {
803 if (entitySelection !== null) callback(entitySelection);
806 // Show selector interface
811 // Hide selector interface
813 element.fadeOut(240);
816 // Listen to confirmation of entity selections (doubleclick)
817 events.listen('entity-select-confirm', entity => {
822 // Show entity selector, Accessible globally, and store the callback
823 window.showEntityLinkSelector = function(passedCallback) {
825 callback = passedCallback;
833 ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
837 link: function (scope, element, attrs) {
838 scope.loading = true;
839 scope.entityResults = false;
842 // Add input for forms
843 const input = element.find('[entity-selector-input]').first();
845 // Detect double click events
847 function isDoubleClick() {
848 let now = Date.now();
849 let answer = now - lastClick < 300;
854 // Listen to entity item clicks
855 element.on('click', '.entity-list a', function(event) {
856 event.preventDefault();
857 event.stopPropagation();
858 let item = $(this).closest('[data-entity-type]');
859 itemSelect(item, isDoubleClick());
861 element.on('click', '[data-entity-type]', function(event) {
862 itemSelect($(this), isDoubleClick());
865 // Select entity action
866 function itemSelect(item, doubleClick) {
867 let entityType = item.attr('data-entity-type');
868 let entityId = item.attr('data-entity-id');
869 let isSelected = !item.hasClass('selected') || doubleClick;
870 element.find('.selected').removeClass('selected').removeClass('primary-background');
871 if (isSelected) item.addClass('selected').addClass('primary-background');
872 let newVal = isSelected ? `${entityType}:${entityId}` : '';
876 events.emit('entity-select-change', null);
879 if (!doubleClick && !isSelected) return;
881 let link = item.find('.entity-list-item-link').attr('href');
882 let name = item.find('.entity-list-item-name').text();
885 events.emit('entity-select-confirm', {
886 id: Number(entityId),
893 events.emit('entity-select-change', {
894 id: Number(entityId),
901 // Get search url with correct types
902 function getSearchUrl() {
903 let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
904 return window.baseUrl(`/ajax/search/entities?types=${types}`);
907 // Get initial contents
908 $http.get(getSearchUrl()).then(resp => {
909 scope.entityResults = $sce.trustAsHtml(resp.data);
910 scope.loading = false;
913 // Search when typing
914 scope.searchEntities = function() {
915 scope.loading = true;
917 let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
918 $http.get(url).then(resp => {
919 scope.entityResults = $sce.trustAsHtml(resp.data);
920 scope.loading = false;