2 var DropZone = require('dropzone');
3 var markdown = require('marked');
5 var toggleSwitchTemplate = require('./components/toggle-switch.html');
6 var imagePickerTemplate = require('./components/image-picker.html');
7 var dropZoneTemplate = require('./components/drop-zone.html');
9 module.exports = function (ngApp, events) {
13 * Has basic on/off functionality.
14 * Use string values of 'true' & 'false' to dictate the current state.
16 ngApp.directive('toggleSwitch', function () {
19 template: toggleSwitchTemplate,
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';
27 scope.switch = function () {
28 scope.isActive = !scope.isActive;
29 scope.value = scope.isActive ? 'true' : 'false';
39 * Is a simple front-end interface that connects to an ImageManager if present.
41 ngApp.directive('imagePicker', ['$http', 'imageManagerService', function ($http, imageManagerService) {
44 template: imagePickerTemplate,
56 link: function (scope, element, attrs) {
57 var 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;
62 function setImage(imageModel, imageUrl) {
63 scope.image = imageUrl;
64 scope.value = usingIds ? imageModel.id : imageUrl;
67 scope.reset = function () {
68 setImage({id: 0}, scope.defaultImage);
71 scope.remove = function () {
76 scope.showImageManager = function () {
77 imageManagerService.show((image) => {
78 scope.updateImageFromModel(image);
82 scope.updateImageFromModel = function (model) {
83 var isResized = scope.resizeWidth && scope.resizeHeight;
87 setImage(model, model.url);
92 var cropped = scope.resizeCrop ? 'true' : 'false';
93 var requestString = '/images/thumb/' + model.id + '/' + scope.resizeWidth + '/' + scope.resizeHeight + '/' + cropped;
94 $http.get(requestString).then((response) => {
95 setImage(model, response.data.url);
105 * Used for uploading images
107 ngApp.directive('dropZone', [function () {
110 template: dropZoneTemplate,
117 link: function (scope, element, attrs) {
118 var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
119 url: scope.uploadUrl,
122 dz.on('sending', function (file, xhr, data) {
123 var token = window.document.querySelector('meta[name=token]').getAttribute('content');
124 data.append('_token', token);
125 var uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
126 data.append('uploaded_to', uploadedTo);
128 if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
129 dz.on('success', function (file, data) {
130 $(file.previewElement).fadeOut(400, function () {
134 if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
135 dz.on('error', function (file, errorMessage, xhr) {
136 console.log(errorMessage);
138 function setMessage(message) {
139 $(file.previewElement).find('[data-dz-errormessage]').text(message);
142 if (xhr.status === 413) setMessage('The server does not allow uploads of this size. Please try a smaller file.');
143 if (errorMessage.file) setMessage(errorMessage.file[0]);
154 * Provides some simple logic to create small dropdown menus
156 ngApp.directive('dropdown', [function () {
159 link: function (scope, element, attrs) {
160 const menu = element.find('ul');
161 element.find('[dropdown-toggle]').on('click', function () {
162 menu.show().addClass('anim menuIn');
163 let inputs = menu.find('input');
164 let hasInput = inputs.length > 0;
166 inputs.first().focus();
167 element.on('keypress', 'input', event => {
168 if (event.keyCode === 13) {
169 event.preventDefault();
171 menu.removeClass('anim menuIn');
176 element.mouseleave(function () {
178 menu.removeClass('anim menuIn');
187 * An angular wrapper around the tinyMCE editor.
189 ngApp.directive('tinymce', ['$timeout', function ($timeout) {
197 link: function (scope, element, attrs) {
199 function tinyMceSetup(editor) {
200 editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
201 var content = editor.getContent();
203 scope.mceModel = content;
205 scope.mceChange(content);
208 editor.on('keydown', (event) => {
209 scope.$emit('editor-keydown', event);
212 editor.on('init', (e) => {
213 scope.mceModel = editor.getContent();
216 scope.$on('html-update', (event, value) => {
217 editor.setContent(value);
218 editor.selection.select(editor.getBody(), true);
219 editor.selection.collapse(false);
220 scope.mceModel = editor.getContent();
224 scope.tinymce.extraSetups.push(tinyMceSetup);
226 // Custom tinyMCE plugins
227 tinymce.PluginManager.add('customhr', function (editor) {
228 editor.addCommand('InsertHorizontalRule', function () {
229 var hrElem = document.createElement('hr');
230 var cNode = editor.selection.getNode();
231 var parentNode = cNode.parentNode;
232 parentNode.insertBefore(hrElem, cNode);
235 editor.addButton('hr', {
237 tooltip: 'Horizontal line',
238 cmd: 'InsertHorizontalRule'
241 editor.addMenuItem('hr', {
243 text: 'Horizontal line',
244 cmd: 'InsertHorizontalRule',
249 tinymce.init(scope.tinymce);
256 * Handles the logic for just the editor input field.
258 ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
265 link: function (scope, element, attrs) {
267 // Set initial model content
268 var content = element.val();
269 scope.mdModel = content;
270 scope.mdChange(markdown(content));
272 element.on('change input', (e) => {
273 content = element.val();
275 scope.mdModel = content;
276 scope.mdChange(markdown(content));
280 scope.$on('markdown-update', (event, value) => {
282 scope.mdModel = value;
283 scope.mdChange(markdown(value));
292 * Handles all functionality of the markdown editor.
294 ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
297 link: function (scope, element, attrs) {
300 const input = element.find('textarea[markdown-input]');
301 const display = element.find('.markdown-display').first();
302 const insertImage = element.find('button[data-action="insertImage"]');
304 let currentCaretPos = 0;
306 input.blur(event => {
307 currentCaretPos = input[0].selectionStart;
311 let inputScrollHeight,
316 function setScrollHeights() {
317 inputScrollHeight = input[0].scrollHeight;
318 inputHeight = input.height();
319 displayScrollHeight = display[0].scrollHeight;
320 displayHeight = display.height();
326 window.addEventListener('resize', setScrollHeights);
327 let scrollDebounceTime = 800;
329 input.on('scroll', event => {
330 let now = Date.now();
331 if (now - lastScroll > scrollDebounceTime) {
334 let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
335 let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
336 display.scrollTop(displayScrollY);
340 // Editor key-presses
341 input.keydown(event => {
342 // Insert image shortcut
343 if (event.which === 73 && event.ctrlKey && event.shiftKey) {
344 event.preventDefault();
345 var caretPos = input[0].selectionStart;
346 var currentContent = input.val();
347 var mdImageText = "";
348 input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
350 input[0].selectionStart = caretPos + (";
351 input[0].selectionEnd = caretPos + (';
354 // Pass key presses to controller via event
355 scope.$emit('editor-keydown', event);
358 // Insert image from image manager
359 insertImage.click(event => {
360 window.ImageManager.showExternal(image => {
361 var caretPos = currentCaretPos;
362 var currentContent = input.val();
363 var mdImageText = "";
364 input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
374 * Page Editor Toolbox
375 * Controls all functionality for the sliding toolbox
376 * on the page edit view.
378 ngApp.directive('toolbox', [function () {
381 link: function (scope, elem, attrs) {
383 // Get common elements
384 const $buttons = elem.find('[tab-button]');
385 const $content = elem.find('[tab-content]');
386 const $toggle = elem.find('[toolbox-toggle]');
388 // Handle toolbox toggle click
389 $toggle.click((e) => {
390 elem.toggleClass('open');
393 // Set an active tab/content by name
394 function setActive(tabName, openToolbox) {
395 $buttons.removeClass('active');
397 $buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
398 $content.filter(`[tab-content="${tabName}"]`).show();
399 if (openToolbox) elem.addClass('open');
402 // Set the first tab content active on load
403 setActive($content.first().attr('tab-content'), false);
405 // Handle tab button click
406 $buttons.click(function (e) {
407 let name = $(this).attr('tab-button');
408 setActive(name, true);
415 * Tag Autosuggestions
416 * Listens to child inputs and provides autosuggestions depending on field type
417 * and input. Suggestions provided by server.
419 ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
422 link: function (scope, elem, attrs) {
424 // Local storage for quick caching.
425 const localCache = {};
427 // Create suggestion element
428 const suggestionBox = document.createElement('ul');
429 suggestionBox.className = 'suggestion-box';
430 suggestionBox.style.position = 'absolute';
431 suggestionBox.style.display = 'none';
432 const $suggestionBox = $(suggestionBox);
434 // General state tracking
435 let isShowing = false;
436 let currentInput = false;
439 // Listen to input events on autosuggest fields
440 elem.on('input focus', '[autosuggest]', function (event) {
441 let $input = $(this);
442 let val = $input.val();
443 let url = $input.attr('autosuggest');
444 let type = $input.attr('autosuggest-type');
446 // Add name param to request if for a value
447 if (type.toLowerCase() === 'value') {
448 let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
449 let nameVal = $nameInput.val();
450 if (nameVal !== '') {
451 url += '?name=' + encodeURIComponent(nameVal);
455 let suggestionPromise = getSuggestions(val.slice(0, 3), url);
456 suggestionPromise.then(suggestions => {
457 if (val.length === 0) {
458 displaySuggestions($input, suggestions.slice(0, 6));
460 suggestions = suggestions.filter(item => {
461 return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
463 displaySuggestions($input, suggestions);
468 // Hide autosuggestions when input loses focus.
469 // Slight delay to allow clicks.
470 let lastFocusTime = 0;
471 elem.on('blur', '[autosuggest]', function (event) {
472 let startTime = Date.now();
474 if (lastFocusTime < startTime) {
475 $suggestionBox.hide();
480 elem.on('focus', '[autosuggest]', function (event) {
481 lastFocusTime = Date.now();
484 elem.on('keydown', '[autosuggest]', function (event) {
485 if (!isShowing) return;
487 let suggestionElems = suggestionBox.childNodes;
488 let suggestCount = suggestionElems.length;
491 if (event.keyCode === 40) {
492 let newActive = (active === suggestCount - 1) ? 0 : active + 1;
493 changeActiveTo(newActive, suggestionElems);
496 else if (event.keyCode === 38) {
497 let newActive = (active === 0) ? suggestCount - 1 : active - 1;
498 changeActiveTo(newActive, suggestionElems);
501 else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
502 let text = suggestionElems[active].textContent;
503 currentInput[0].value = text;
504 currentInput.focus();
505 $suggestionBox.hide();
507 if (event.keyCode === 13) {
508 event.preventDefault();
514 // Change the active suggestion to the given index
515 function changeActiveTo(index, suggestionElems) {
516 suggestionElems[active].className = '';
518 suggestionElems[active].className = 'active';
521 // Display suggestions on a field
522 let prevSuggestions = [];
524 function displaySuggestions($input, suggestions) {
526 // Hide if no suggestions
527 if (suggestions.length === 0) {
528 $suggestionBox.hide();
530 prevSuggestions = suggestions;
534 // Otherwise show and attach to input
536 $suggestionBox.show();
539 if ($input !== currentInput) {
540 $suggestionBox.detach();
541 $input.after($suggestionBox);
542 currentInput = $input;
545 // Return if no change
546 if (prevSuggestions.join() === suggestions.join()) {
547 prevSuggestions = suggestions;
552 $suggestionBox[0].innerHTML = '';
553 for (let i = 0; i < suggestions.length; i++) {
554 var suggestion = document.createElement('li');
555 suggestion.textContent = suggestions[i];
556 suggestion.onclick = suggestionClick;
558 suggestion.className = 'active'
562 $suggestionBox[0].appendChild(suggestion);
565 prevSuggestions = suggestions;
568 // Suggestion click event
569 function suggestionClick(event) {
570 let text = this.textContent;
571 currentInput[0].value = text;
572 currentInput.focus();
573 $suggestionBox.hide();
577 // Get suggestions & cache
578 function getSuggestions(input, url) {
579 let hasQuery = url.indexOf('?') !== -1;
580 let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
582 // Get from local cache if exists
583 if (typeof localCache[searchUrl] !== 'undefined') {
584 return new Promise((resolve, reject) => {
585 resolve(localCache[searchUrl]);
589 return $http.get(searchUrl).then(response => {
590 localCache[searchUrl] = response.data;
591 return response.data;
600 ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
604 link: function (scope, element, attrs) {
605 scope.loading = true;
606 scope.entityResults = false;
609 // Add input for forms
610 const input = element.find('[entity-selector-input]').first();
612 // Listen to entity item clicks
613 element.on('click', '.entity-list a', function(event) {
614 event.preventDefault();
615 event.stopPropagation();
616 let item = $(this).closest('[data-entity-type]');
619 element.on('click', '[data-entity-type]', function(event) {
623 // Select entity action
624 function itemSelect(item) {
625 let entityType = item.attr('data-entity-type');
626 let entityId = item.attr('data-entity-id');
627 let isSelected = !item.hasClass('selected');
628 element.find('.selected').removeClass('selected').removeClass('primary-background');
629 if (isSelected) item.addClass('selected').addClass('primary-background');
630 let newVal = isSelected ? `${entityType}:${entityId}` : '';
634 // Get search url with correct types
635 function getSearchUrl() {
636 let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
637 return `/ajax/search/entities?types=${types}`;
640 // Get initial contents
641 $http.get(getSearchUrl()).then(resp => {
642 scope.entityResults = $sce.trustAsHtml(resp.data);
643 scope.loading = false;
646 // Search when typing
647 scope.searchEntities = function() {
648 scope.loading = true;
650 let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
651 $http.get(url).then(resp => {
652 scope.entityResults = $sce.trustAsHtml(resp.data);
653 scope.loading = false;