2 const DropZone = require("dropzone");
3 const MarkdownIt = require("markdown-it");
4 const mdTasksLists = require('markdown-it-task-lists');
6 module.exports = function (ngApp, events) {
9 * Common tab controls using simple jQuery functions.
11 ngApp.directive('tabContainer', function() {
14 link: function (scope, element, attrs) {
15 const $content = element.find('[tab-content]');
16 const $buttons = element.find('[tab-button]');
18 if (attrs.tabContainer) {
19 let initial = attrs.tabContainer;
20 $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
21 $content.hide().filter(`[tab-content="${initial}"]`).show();
23 $content.hide().first().show();
24 $buttons.first().addClass('selected');
27 $buttons.click(function() {
28 let clickedTab = $(this);
29 $buttons.removeClass('selected');
31 let name = clickedTab.addClass('selected').attr('tab-button');
32 $content.filter(`[tab-content="${name}"]`).show();
39 * Sub form component to allow inner-form sections to act like their own forms.
41 ngApp.directive('subForm', function() {
44 link: function (scope, element, attrs) {
45 element.on('keypress', e => {
46 if (e.keyCode === 13) {
51 element.find('button[type="submit"]').click(submitEvent);
53 function submitEvent(e) {
55 if (attrs.subForm) scope.$eval(attrs.subForm);
63 * Used for uploading images
65 ngApp.directive('dropZone', [function () {
69 <div class="dropzone-container">
70 <div class="dz-message">{{message}}</div>
79 link: function (scope, element, attrs) {
80 scope.message = attrs.message;
81 if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
82 let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
86 dz.on('sending', function (file, xhr, data) {
87 let token = window.document.querySelector('meta[name=token]').getAttribute('content');
88 data.append('_token', token);
89 let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
90 data.append('uploaded_to', uploadedTo);
92 if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
93 dz.on('success', function (file, data) {
94 $(file.previewElement).fadeOut(400, function () {
98 if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
99 dz.on('error', function (file, errorMessage, xhr) {
100 console.log(errorMessage);
102 function setMessage(message) {
103 $(file.previewElement).find('[data-dz-errormessage]').text(message);
106 if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
107 if (errorMessage.file) setMessage(errorMessage.file[0]);
118 * Provides some simple logic to create small dropdown menus
120 ngApp.directive('dropdown', [function () {
123 link: function (scope, element, attrs) {
124 const menu = element.find('ul');
125 element.find('[dropdown-toggle]').on('click', function () {
126 menu.show().addClass('anim menuIn');
127 let inputs = menu.find('input');
128 let hasInput = inputs.length > 0;
130 inputs.first().focus();
131 element.on('keypress', 'input', event => {
132 if (event.keyCode === 13) {
133 event.preventDefault();
135 menu.removeClass('anim menuIn');
140 element.mouseleave(function () {
142 menu.removeClass('anim menuIn');
151 * An angular wrapper around the tinyMCE editor.
153 ngApp.directive('tinymce', ['$timeout', function ($timeout) {
161 link: function (scope, element, attrs) {
163 function tinyMceSetup(editor) {
164 editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
165 let content = editor.getContent();
167 scope.mceModel = content;
169 scope.mceChange(content);
172 editor.on('keydown', (event) => {
173 scope.$emit('editor-keydown', event);
176 editor.on('init', (e) => {
177 scope.mceModel = editor.getContent();
180 scope.$on('html-update', (event, value) => {
181 editor.setContent(value);
182 editor.selection.select(editor.getBody(), true);
183 editor.selection.collapse(false);
184 scope.mceModel = editor.getContent();
188 scope.tinymce.extraSetups.push(tinyMceSetup);
190 // Custom tinyMCE plugins
191 tinymce.PluginManager.add('customhr', function (editor) {
192 editor.addCommand('InsertHorizontalRule', function () {
193 let hrElem = document.createElement('hr');
194 let cNode = editor.selection.getNode();
195 let parentNode = cNode.parentNode;
196 parentNode.insertBefore(hrElem, cNode);
199 editor.addButton('hr', {
201 tooltip: 'Horizontal line',
202 cmd: 'InsertHorizontalRule'
205 editor.addMenuItem('hr', {
207 text: 'Horizontal line',
208 cmd: 'InsertHorizontalRule',
213 tinymce.init(scope.tinymce);
218 const md = new MarkdownIt({html: true});
219 md.use(mdTasksLists, {label: true});
223 * Handles the logic for just the editor input field.
225 ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
232 link: function (scope, element, attrs) {
234 // Set initial model content
235 element = element.find('textarea').first();
236 let content = element.val();
237 scope.mdModel = content;
238 scope.mdChange(md.render(content));
240 element.on('change input', (event) => {
241 content = element.val();
243 scope.mdModel = content;
244 scope.mdChange(md.render(content));
248 scope.$on('markdown-update', (event, value) => {
250 scope.mdModel = value;
251 scope.mdChange(md.render(value));
260 * Handles all functionality of the markdown editor.
262 ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
265 link: function (scope, element, attrs) {
268 const $input = element.find('[markdown-input] textarea').first();
269 const $display = element.find('.markdown-display').first();
270 const $insertImage = element.find('button[data-action="insertImage"]');
271 const $insertEntityLink = element.find('button[data-action="insertEntityLink"]');
273 // Prevent markdown display link click redirect
274 $display.on('click', 'a', function(event) {
275 event.preventDefault();
276 window.open(this.getAttribute('href'));
279 let currentCaretPos = 0;
281 $input.blur(event => {
282 currentCaretPos = $input[0].selectionStart;
286 let inputScrollHeight,
291 function setScrollHeights() {
292 inputScrollHeight = $input[0].scrollHeight;
293 inputHeight = $input.height();
294 displayScrollHeight = $display[0].scrollHeight;
295 displayHeight = $display.height();
301 window.addEventListener('resize', setScrollHeights);
302 let scrollDebounceTime = 800;
304 $input.on('scroll', event => {
305 let now = Date.now();
306 if (now - lastScroll > scrollDebounceTime) {
309 let scrollPercent = ($input.scrollTop() / (inputScrollHeight - inputHeight));
310 let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
311 $display.scrollTop(displayScrollY);
315 // Editor key-presses
316 $input.keydown(event => {
317 // Insert image shortcut
318 if (event.which === 73 && event.ctrlKey && event.shiftKey) {
319 event.preventDefault();
320 let caretPos = $input[0].selectionStart;
321 let currentContent = $input.val();
322 const mdImageText = "";
323 $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
325 $input[0].selectionStart = caretPos + (";
326 $input[0].selectionEnd = caretPos + (';
330 // Insert entity link shortcut
331 if (event.which === 75 && event.ctrlKey && event.shiftKey) {
336 // Pass key presses to controller via event
337 scope.$emit('editor-keydown', event);
340 // Insert image from image manager
341 $insertImage.click(event => {
342 window.ImageManager.showExternal(image => {
343 let caretPos = currentCaretPos;
344 let currentContent = $input.val();
345 let mdImageText = "";
346 $input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
351 function showLinkSelector() {
352 window.showEntityLinkSelector((entity) => {
353 let selectionStart = currentCaretPos;
354 let selectionEnd = $input[0].selectionEnd;
355 let textSelected = (selectionEnd !== selectionStart);
356 let currentContent = $input.val();
359 let selectedText = currentContent.substring(selectionStart, selectionEnd);
360 let linkText = `[${selectedText}](${entity.link})`;
361 $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionEnd));
363 let linkText = ` [${entity.name}](${entity.link}) `;
364 $input.val(currentContent.substring(0, selectionStart) + linkText + currentContent.substring(selectionStart))
369 $insertEntityLink.click(showLinkSelector);
371 // Upload and insert image on paste
372 function editorPaste(e) {
374 if (!e.clipboardData) return
375 let items = e.clipboardData.items;
377 for (let i = 0; i < items.length; i++) {
378 uploadImage(items[i].getAsFile());
382 $input.on('paste', editorPaste);
384 // Handle image drop, Uploads images to BookStack.
385 function handleImageDrop(event) {
386 event.stopPropagation();
387 event.preventDefault();
388 let files = event.originalEvent.dataTransfer.files;
389 for (let i = 0; i < files.length; i++) {
390 uploadImage(files[i]);
394 $input.on('drop', handleImageDrop);
396 // Handle image upload and add image into markdown content
397 function uploadImage(file) {
398 if (file.type.indexOf('image') !== 0) return;
399 let formData = new FormData();
401 let xhr = new XMLHttpRequest();
404 let fileNameMatches = file.name.match(/\.(.+)$/);
405 if (fileNameMatches) {
406 ext = fileNameMatches[1];
410 // Insert image into markdown
411 let id = "image-" + Math.random().toString(16).slice(2);
412 let selectStart = $input[0].selectionStart;
413 let selectEnd = $input[0].selectionEnd;
414 let content = $input[0].value;
415 let selectText = content.substring(selectStart, selectEnd);
416 let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`);
417 let innerContent = ((selectEnd > selectStart) ? `![${selectText}]` : '![]') + `(${placeholderImage})`;
418 $input[0].value = content.substring(0, selectStart) + innerContent + content.substring(selectEnd);
421 $input[0].selectionStart = selectStart;
422 $input[0].selectionEnd = selectStart;
424 let remoteFilename = "image-" + Date.now() + "." + ext;
425 formData.append('file', file, remoteFilename);
426 formData.append('_token', document.querySelector('meta[name="token"]').getAttribute('content'));
428 xhr.open('POST', window.baseUrl('/images/gallery/upload'));
429 xhr.onload = function () {
430 let selectStart = $input[0].selectionStart;
431 if (xhr.status === 200 || xhr.status === 201) {
432 let result = JSON.parse(xhr.responseText);
433 $input[0].value = $input[0].value.replace(placeholderImage, result.thumbs.display);
436 console.log(trans('errors.image_upload_error'));
437 console.log(xhr.responseText);
438 $input[0].value = $input[0].value.replace(innerContent, '');
442 $input[0].selectionStart = selectStart;
443 $input[0].selectionEnd = selectStart;
453 * Page Editor Toolbox
454 * Controls all functionality for the sliding toolbox
455 * on the page edit view.
457 ngApp.directive('toolbox', [function () {
460 link: function (scope, elem, attrs) {
462 // Get common elements
463 const $buttons = elem.find('[toolbox-tab-button]');
464 const $content = elem.find('[toolbox-tab-content]');
465 const $toggle = elem.find('[toolbox-toggle]');
467 // Handle toolbox toggle click
468 $toggle.click((e) => {
469 elem.toggleClass('open');
472 // Set an active tab/content by name
473 function setActive(tabName, openToolbox) {
474 $buttons.removeClass('active');
476 $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
477 $content.filter(`[toolbox-tab-content="${tabName}"]`).show();
478 if (openToolbox) elem.addClass('open');
481 // Set the first tab content active on load
482 setActive($content.first().attr('toolbox-tab-content'), false);
484 // Handle tab button click
485 $buttons.click(function (e) {
486 let name = $(this).attr('toolbox-tab-button');
487 setActive(name, true);
494 * Tag Autosuggestions
495 * Listens to child inputs and provides autosuggestions depending on field type
496 * and input. Suggestions provided by server.
498 ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
501 link: function (scope, elem, attrs) {
503 // Local storage for quick caching.
504 const localCache = {};
506 // Create suggestion element
507 const suggestionBox = document.createElement('ul');
508 suggestionBox.className = 'suggestion-box';
509 suggestionBox.style.position = 'absolute';
510 suggestionBox.style.display = 'none';
511 const $suggestionBox = $(suggestionBox);
513 // General state tracking
514 let isShowing = false;
515 let currentInput = false;
518 // Listen to input events on autosuggest fields
519 elem.on('input focus', '[autosuggest]', function (event) {
520 let $input = $(this);
521 let val = $input.val();
522 let url = $input.attr('autosuggest');
523 let type = $input.attr('autosuggest-type');
525 // Add name param to request if for a value
526 if (type.toLowerCase() === 'value') {
527 let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
528 let nameVal = $nameInput.val();
529 if (nameVal !== '') {
530 url += '?name=' + encodeURIComponent(nameVal);
534 let suggestionPromise = getSuggestions(val.slice(0, 3), url);
535 suggestionPromise.then(suggestions => {
536 if (val.length === 0) {
537 displaySuggestions($input, suggestions.slice(0, 6));
539 suggestions = suggestions.filter(item => {
540 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
542 displaySuggestions($input, suggestions);
547 // Hide autosuggestions when input loses focus.
548 // Slight delay to allow clicks.
549 let lastFocusTime = 0;
550 elem.on('blur', '[autosuggest]', function (event) {
551 let startTime = Date.now();
553 if (lastFocusTime < startTime) {
554 $suggestionBox.hide();
559 elem.on('focus', '[autosuggest]', function (event) {
560 lastFocusTime = Date.now();
563 elem.on('keydown', '[autosuggest]', function (event) {
564 if (!isShowing) return;
566 let suggestionElems = suggestionBox.childNodes;
567 let suggestCount = suggestionElems.length;
570 if (event.keyCode === 40) {
571 let newActive = (active === suggestCount - 1) ? 0 : active + 1;
572 changeActiveTo(newActive, suggestionElems);
575 else if (event.keyCode === 38) {
576 let newActive = (active === 0) ? suggestCount - 1 : active - 1;
577 changeActiveTo(newActive, suggestionElems);
580 else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
581 currentInput[0].value = suggestionElems[active].textContent;
582 currentInput.focus();
583 $suggestionBox.hide();
585 if (event.keyCode === 13) {
586 event.preventDefault();
592 // Change the active suggestion to the given index
593 function changeActiveTo(index, suggestionElems) {
594 suggestionElems[active].className = '';
596 suggestionElems[active].className = 'active';
599 // Display suggestions on a field
600 let prevSuggestions = [];
602 function displaySuggestions($input, suggestions) {
604 // Hide if no suggestions
605 if (suggestions.length === 0) {
606 $suggestionBox.hide();
608 prevSuggestions = suggestions;
612 // Otherwise show and attach to input
614 $suggestionBox.show();
617 if ($input !== currentInput) {
618 $suggestionBox.detach();
619 $input.after($suggestionBox);
620 currentInput = $input;
623 // Return if no change
624 if (prevSuggestions.join() === suggestions.join()) {
625 prevSuggestions = suggestions;
630 $suggestionBox[0].innerHTML = '';
631 for (let i = 0; i < suggestions.length; i++) {
632 let suggestion = document.createElement('li');
633 suggestion.textContent = suggestions[i];
634 suggestion.onclick = suggestionClick;
636 suggestion.className = 'active';
639 $suggestionBox[0].appendChild(suggestion);
642 prevSuggestions = suggestions;
645 // Suggestion click event
646 function suggestionClick(event) {
647 currentInput[0].value = this.textContent;
648 currentInput.focus();
649 $suggestionBox.hide();
653 // Get suggestions & cache
654 function getSuggestions(input, url) {
655 let hasQuery = url.indexOf('?') !== -1;
656 let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
658 // Get from local cache if exists
659 if (typeof localCache[searchUrl] !== 'undefined') {
660 return new Promise((resolve, reject) => {
661 resolve(localCache[searchUrl]);
665 return $http.get(searchUrl).then(response => {
666 localCache[searchUrl] = response.data;
667 return response.data;
675 ngApp.directive('entityLinkSelector', [function($http) {
678 link: function(scope, element, attrs) {
680 const selectButton = element.find('.entity-link-selector-confirm');
681 let callback = false;
682 let entitySelection = null;
684 // Handle entity selection change, Stores the selected entity locally
685 function entitySelectionChange(entity) {
686 entitySelection = entity;
687 if (entity === null) {
688 selectButton.attr('disabled', 'true');
690 selectButton.removeAttr('disabled');
693 events.listen('entity-select-change', entitySelectionChange);
695 // Handle selection confirm button click
696 selectButton.click(event => {
698 if (entitySelection !== null) callback(entitySelection);
701 // Show selector interface
706 // Hide selector interface
708 element.fadeOut(240);
711 // Listen to confirmation of entity selections (doubleclick)
712 events.listen('entity-select-confirm', entity => {
717 // Show entity selector, Accessible globally, and store the callback
718 window.showEntityLinkSelector = function(passedCallback) {
720 callback = passedCallback;
728 ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
732 link: function (scope, element, attrs) {
733 scope.loading = true;
734 scope.entityResults = false;
737 // Add input for forms
738 const input = element.find('[entity-selector-input]').first();
740 // Detect double click events
742 function isDoubleClick() {
743 let now = Date.now();
744 let answer = now - lastClick < 300;
749 // Listen to entity item clicks
750 element.on('click', '.entity-list a', function(event) {
751 event.preventDefault();
752 event.stopPropagation();
753 let item = $(this).closest('[data-entity-type]');
754 itemSelect(item, isDoubleClick());
756 element.on('click', '[data-entity-type]', function(event) {
757 itemSelect($(this), isDoubleClick());
760 // Select entity action
761 function itemSelect(item, doubleClick) {
762 let entityType = item.attr('data-entity-type');
763 let entityId = item.attr('data-entity-id');
764 let isSelected = !item.hasClass('selected') || doubleClick;
765 element.find('.selected').removeClass('selected').removeClass('primary-background');
766 if (isSelected) item.addClass('selected').addClass('primary-background');
767 let newVal = isSelected ? `${entityType}:${entityId}` : '';
771 events.emit('entity-select-change', null);
774 if (!doubleClick && !isSelected) return;
776 let link = item.find('.entity-list-item-link').attr('href');
777 let name = item.find('.entity-list-item-name').text();
780 events.emit('entity-select-confirm', {
781 id: Number(entityId),
788 events.emit('entity-select-change', {
789 id: Number(entityId),
796 // Get search url with correct types
797 function getSearchUrl() {
798 let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
799 return window.baseUrl(`/ajax/search/entities?types=${types}`);
802 // Get initial contents
803 $http.get(getSearchUrl()).then(resp => {
804 scope.entityResults = $sce.trustAsHtml(resp.data);
805 scope.loading = false;
808 // Search when typing
809 scope.searchEntities = function() {
810 scope.loading = true;
812 let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
813 $http.get(url).then(resp => {
814 scope.entityResults = $sce.trustAsHtml(resp.data);
815 scope.loading = false;
823 ngApp.directive('simpleMarkdownInput', ['$timeout', function ($timeout) {
832 link: function (scope, element, attrs) {
833 // Set initial model content
834 element = element.find('textarea').first();
835 let simplemde = new SimpleMDE({
839 let content = element.val();
840 simplemde.value(content)
841 scope.smdModel = content;
843 simplemde.codemirror.on('change', (event) => {
844 content = simplemde.value();
846 scope.smdModel = content;
847 if (scope.smdChange) {
848 scope.smdChange(element, content);
853 if ('smdGetContent' in attrs) {
854 scope.smdGetContent = function () {
855 return simplemde.options.previewRender(simplemde.value());
859 if ('smdClear' in attrs) {
860 scope.smdClear = function () {
869 ngApp.directive('commentReply', ['$timeout', function ($timeout) {
872 templateUrl: 'comment-reply.html',
876 link: function (scope, element, attr) {
883 ngApp.directive('commentReplyLink', ['$document', '$compile', function ($document, $compile) {
885 link: function (scope, element, attr) {
886 element.on('$destroy', function () {
887 element.off('click');
891 element.on('click', function () {
892 var $container = element.parents('.comment-box').first();
893 if (!$container.length) {
894 console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
897 if (attr.noCommentReplyDupe) {
900 var compiledHTML = $compile('<comment-reply></comment-reply>')(scope);
901 $container.append(compiledHTML);
907 function removeDupe() {
908 let $existingElement = $document.find('comment-reply');
909 if (!$existingElement.length) {
913 $existingElement.remove();