1/** 2 * Project: Minerva KB 3 * Copyright: 2015-2016 @KonstruktStudio 4 */ 5(function($) { 6 'use strict'; 7 8 var GLOBAL_DATA = window.MinervaKB || {}; // TODO: broken in admin 9 var settings = GLOBAL_DATA.settings || {}; 10 // var ajaxUrl = GLOBAL_DATA.ajaxUrl; 11 // var i18n = GLOBAL_DATA.i18n; 12 13 // polyfills 14 // https://tc39.github.io/ecma262/#sec-array.prototype.includes 15 if (!Array.prototype.includes) { 16 Object.defineProperty(Array.prototype, 'includes', { 17 value: function(searchElement, fromIndex) { 18 19 if (this == null) { 20 throw new TypeError('"this" is null or not defined'); 21 } 22 23 // 1. Let O be ? ToObject(this value). 24 var o = Object(this); 25 26 // 2. Let len be ? ToLength(? Get(O, "length")). 27 var len = o.length >>> 0; 28 29 // 3. If len is 0, return false. 30 if (len === 0) { 31 return false; 32 } 33 34 // 4. Let n be ? ToInteger(fromIndex). 35 // (If fromIndex is undefined, this step produces the value 0.) 36 var n = fromIndex | 0; 37 38 // 5. If n ⥠0, then 39 // a. Let k be n. 40 // 6. Else n < 0, 41 // a. Let k be len + n. 42 // b. If k < 0, let k be 0. 43 var k = Math.max(n >= 0 ? n : len - Math.abs(n), 0); 44 45 function sameValueZero(x, y) { 46 return x === y || (typeof x === 'number' && typeof y === 'number' && isNaN(x) && isNaN(y)); 47 } 48 49 // 7. Repeat, while k < len 50 while (k < len) { 51 // a. Let elementK be the result of ? Get(O, ! ToString(k)). 52 // b. If SameValueZero(searchElement, elementK) is true, return true. 53 if (sameValueZero(o[k], searchElement)) { 54 return true; 55 } 56 // c. Increase k by 1. 57 k++; 58 } 59 60 // 8. Return false 61 return false; 62 } 63 }); 64 } 65 66 /** 67 * General utils 68 */ 69 70 function makeArray(thing) { 71 if (!thing) { 72 return []; 73 } 74 75 return [].slice.apply(thing); 76 } 77 78 function getElementIndex(node) { 79 var index = 0; 80 81 while ((node = node.previousElementSibling)) { 82 index++; 83 } 84 85 return index; 86 } 87 88 function humanFileSize(bytes, si) { 89 var thresh = si ? 1000 : 1024; 90 91 if (Math.abs(bytes) < thresh) { 92 return bytes + 'B'; 93 } 94 95 var units = si 96 ? ['kB','MB','GB','TB','PB','EB','ZB','YB'] 97 : ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB']; 98 var u = -1; 99 100 do { 101 bytes /= thresh; 102 ++u; 103 } while(Math.abs(bytes) >= thresh && u < units.length - 1); 104 105 return bytes.toFixed(1) + '' + units[u]; 106 } 107 108 /** 109 * Quill editor with optional file uploader 110 * @param options 111 */ 112 function setupQuillEditor(options) { 113 var $form = options.$form; 114 var isStandaloneUploadButton = settings['tickets_create_use_standalone_upload_button']; 115 116 var toolbarOptions = options.toolbarOptions || [ 117 [{'header': [1, 2, 3, 4, 5, 6, false]}], 118 ['bold', 'italic', 'underline', 'strike'], 119 [{'list': 'ordered'}, {'list': 'bullet'}], 120 ['link'], 121 ['blockquote', 'code-block'], 122 ['clean'] 123 ]; 124 125 if (options.fileUpload && !isStandaloneUploadButton) { 126 toolbarOptions.push(['upload']); 127 } 128 129 var quillEditor = new Quill(options.editorSelector, { 130 modules: { 131 toolbar: toolbarOptions, 132 history: { 133 delay: 2000, 134 maxStack: 500, 135 userOnly: true 136 } 137 }, 138 placeholder: options.placeholder || 'Write your message here...', // TODO: TK
139 theme: options.theme || 'snow' // TODO: fix 140 }); 141 142 // remove formatting on paste, leave only strings 143 quillEditor.clipboard.addMatcher(Node.ELEMENT_NODE, function (node, delta) { 144 var ops = []; 145 146 delta.ops.forEach(function(op) { 147 var urlRegex = /https?:\/\/[^\s]+/g; 148 149 if (op.insert && typeof op.insert === 'string') { 150 if (op.attributes && op.attributes.link) { 151 // preserve links. TODO: maybe add safe attributes 152 ops.push({ insert: op.insert.trim(), attributes: { link: op.attributes.link } }); 153 } else { 154 ops.push({ insert: op.insert }); 155 } 156 } 157 }); 158 159 delta.ops = ops; 160 161 return delta; 162 }); 163 164 if (!options.fileUpload) { 165 return quillEditor; 166 } 167 168 var isAttachmentsOpen = false; 169 var uploadButton = isStandaloneUploadButton ? $form.find('.js-mkb-ticket-attach-files')[0] : $form.find('.ql-upload')[0]; 170 var $attachmentsSection = $form.find('.js-mkb-editor-attachments-section'); 171 172 /** 173 * Attachments 174 */ 175 uploadButton.addEventListener('click', function(e) { 176 e.preventDefault(); 177 178 if (!isStandaloneUploadButton) { 179 uploadButton.classList.toggle('state--on'); 180 } 181 182 $attachmentsSection[isAttachmentsOpen ? 'slideUp' : 'slideDown'](250); 183 isAttachmentsOpen = !isAttachmentsOpen; 184 }); 185 186 setupFileUpload({ 187 $dropArea: $form.find('.js-mkb-editor-attachments-drop-area'), 188 $form: $form 189 }); 190 191 return quillEditor; 192 } 193 194 /** 195 * File uploader 196 */ 197 function setupFileUpload(options) { 198 var $dropArea = options.$dropArea; 199 var $form = options.$form; 200 201 if (!$dropArea || !$dropArea.length || !$form || !$form.length) { 202 console.log('Could not initialize file uploader'); 203 204 return; 205 } 206 207 var dropArea = $dropArea.get(0); 208 209 var $preview = $dropArea.find('.js-mkb-file-upload-preview'); 210 var $clearFiles = $dropArea.find('.js-mkb-file-upload-clear'); 211 var $uploadBtn = $dropArea.find(".js-mkb-file-upload-store"); 212 var $fileStore = $uploadBtn; // alias 213 var $dropErrors = $dropArea.find('.js-mkb-file-upload-drop-errors'); 214 215 // control options 216 var allowedTypes = $fileStore.attr('accept') || ''; 217 var maxFiles = $fileStore.data('maxFiles'); 218 var maxFileSizeInMb = $fileStore.data('maxFileSize'); 219 220 $form.attr('enctype', 'multipart/form-data'); 221 222 allowedTypes = allowedTypes.split(',').map(function(type) { 223 return type.trim().replace('.', ''); 224 }); 225 226 ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function(eventName) { 227 /** 228 * NOTE: Can affect other plugins on page, watch this closely 229 * Needed to avoid opening of dropped file in browser. 230 * Must be on body or wider container, otherwise browser will show drop icon outside drop area 231 */ 232 document.body.addEventListener(eventName, function preventDefaults(e) { 233 e.preventDefault(); 234 e.stopPropagation(); 235 }, false); 236 }); 237 238 // Highlight drop area when item is dragged over it 239 ['dragenter', 'dragover'].forEach(function(eventName) { 240 dropArea.addEventListener(eventName, function highlight() { 241 $dropArea.addClass('mkb-drop-highlight'); 242 }, false); 243 }); 244 245 ['dragleave', 'drop'].forEach(function(eventName) { 246 dropArea.addEventListener(eventName, function unhighlight() { 247 $dropArea.removeClass('mkb-drop-highlight'); 248 }, false); 249 }); 250 251 // Handle dropped files 252 dropArea.addEventListener('drop', function(e) { 253 handleAddedFiles(e.dataTransfer.files, true); 254 } , false); 255 256 // upload btn click 257 $uploadBtn.get(0).addEventListener('change', function() { 258 handleAddedFiles(this.files); 259 }, false); 260 261 function createDataTransferObject() { 262 return new ClipboardEvent('').clipboardData || new DataTransfer(); 263 } 264 265 // errors state 266 var hasNotAllowedFilesError = false;
267 var hasTooLargeFilesError = false; 268 var hasTooManyFiles = false; 269 var fileAlreadyAddedError = false; 270 271 function handleAddedFiles(addedFiles, isDragAndDrop) { 272 isDragAndDrop = isDragAndDrop || false; 273 274 // TODO: merge all files, check across browsers 275 var oldFilesArr = isDragAndDrop ? makeArray($fileStore.prop('files')) : []; 276 var newFilesArray = makeArray(addedFiles); 277 var oldFileNames = oldFilesArr.map(function(item) { return item.name; }); 278 279 // reset errors state 280 hasNotAllowedFilesError = false; 281 hasTooLargeFilesError = false; 282 hasTooManyFiles = false; 283 fileAlreadyAddedError = false; 284 285 if (oldFilesArr.length >= maxFiles) { 286 hasTooManyFiles = true; 287 288 handleAddFileErrors(); 289 290 return; 291 } 292 293 console.log('adding files', newFilesArray); 294 295 newFilesArray = newFilesArray.filter(function(item) { 296 var extension = item.name.split('.').pop().toLowerCase(); 297 var isAllowed = allowedTypes.includes(extension); 298 var isValidSize = item.size / (1024 * 1024) <= maxFileSizeInMb; 299 var isAlreadyAdded = oldFileNames.includes(item.name); 300 301 if (!isAllowed) { 302 hasNotAllowedFilesError = true; 303 } 304 305 if (!isValidSize) { 306 hasTooLargeFilesError = true; 307 } 308 309 if (isAlreadyAdded) { 310 fileAlreadyAddedError = true; 311 } 312 313 return isAllowed && isValidSize && !isAlreadyAdded; 314 }); 315 316 var allFilesArr = oldFilesArr.concat(newFilesArray); 317 318 if (allFilesArr.length > maxFiles) { 319 hasTooManyFiles = true; 320 321 allFilesArr = allFilesArr.slice(0, maxFiles); 322 } 323 324 var allFiles = createDataTransferObject(); 325 326 allFilesArr.forEach(function(file) { 327 allFiles.items.add(file); 328 }); 329 330 if (allFiles.files.length) { 331 $fileStore.prop('files', allFiles.files); 332 } else { 333 clearFiles(); 334 } 335 336 handleAddFileErrors(); 337 updatePreview(allFiles.files); 338 339 console.log('[DEBUG]: Files in storage', $fileStore.prop('files')); 340 } 341 342 /** 343 * Process and display file add errors 344 */ 345 function handleAddFileErrors() { 346 clearErrors(); 347 348 var addFileErrors = []; 349 350 if (hasNotAllowedFilesError) { 351 // TODO: translations 352 addFileErrors.push('Some of the added files are not allowed'); 353 } 354 355 if (hasTooLargeFilesError) { 356 // TODO: translations 357 addFileErrors.push('Some of the added files are too large'); 358 } 359 360 if (hasTooManyFiles) { 361 // TODO: translations 362 addFileErrors.push('Maximum file limit is reached, some files were not added'); 363 } 364 365 if (fileAlreadyAddedError) { 366 // TODO: translations 367 addFileErrors.push('Some of the files are already added'); 368 } 369 370 if (addFileErrors.length) { 371 $dropErrors.html( 372 addFileErrors.reduce(function(html, error) { 373 return html + '<div>' + error + '</div>' 374 }, '') 375 ); 376 377 // TODO: clear errors somehow, or highlight when they change but look the same 378 } 379 } 380 381 function clearErrors() { 382 $dropErrors.html(''); 383 } 384 385 /** 386 * Preview update 387 * @param files 388 */ 389 function updatePreview(files) { 390 $preview.html(''); 391 392 // not an array usually 393 files = makeArray(files); 394 395 files.forEach(function addFilePreview(file) { 396 var isImage = /^image\//.test(file.type); 397 var $item = $( 398 '<div class="js-mkb-attachment-upload-preview-item mkb-attachment-upload-preview-item">' + 399 '<a href="#" class="js-mkb-attachment-preview-remove mkb-attachment-preview-remove fa fa-times-circle"></a>' + 400 '</div>' 401 ); 402 403 if (isImage) { 404 var reader = new FileReader();
405 reader.readAsDataURL(file); 406 407 reader.onloadend = function() { 408 var img = document.createElement('img'); 409 img.src = reader.result; 410 $item.append(img); 411 }; 412 413 $item.addClass('type--image'); 414 } else { 415 // non-image files 416 $item.append('<span>' + file.name + ' (' + humanFileSize(file.size, true) + ')' + '</span>'); 417 $item.addClass('type--file'); 418 } 419 420 $preview.append($item); 421 }); 422 } 423 424 /** 425 * Remove file by icon click 426 */ 427 $preview.on('click', '.js-mkb-attachment-preview-remove', function(e) { 428 e.preventDefault(); 429 430 var indexToRemove = getElementIndex(e.currentTarget.parentNode); 431 var currentFilesArray = makeArray($fileStore.prop('files')); 432 var updatedFiles = createDataTransferObject(); 433 434 currentFilesArray.filter(function(file, index) { 435 return index !== indexToRemove; 436 }
436).forEach(function(file, index) { 437 updatedFiles.items.add(file); 438 }); 439 440 if (updatedFiles.files.length) { 441 $fileStore.prop('files', updatedFiles.files); 442 updatePreview(updatedFiles.files); 443 444 console.log('[DEBUG]: Files in storage', $fileStore.prop('files')); 445 } else { 446 clearFiles(); 447 } 448 449 clearErrors(); 450 }); 451 452 function clearFiles() { 453 $fileStore.val(null); // if causes issues across browser, replace with clone 454 updatePreview([]); 455 456 console.log('[DEBUG]: Files in storage', $fileStore.prop('files')); 457 } 458 459 $clearFiles.on('click', function(e) { 460 e.preventDefault(); 461 e.stopImmediatePropagation(); 462 463 clearFiles(); 464 clearErrors(); 465 }); 466 } 467 468 /** 469 * 470 * @param el 471 * @param options 472 * @constructor 473 */ 474 function Form(el, options) { 475 options = options || {}; 476 477 // state 478 this._isLoading = false; 479 480 // options 481 this.options = options; 482 483 // controls data 484 this.controls = {}; 485 this.quillEditors = this.options.quillEditors || []; 486 487 // DOM 488 this.el = el; 489 this.$el = $(el); 490 this.$messagesEl = this.$el.find('.js-mkb-form-messages'); 491 this.$submit = this.$el.find('.js-mkb-form-submit'); 492 493 // labels 494 this.submitLabel = this.$submit.val(); 495 this.submitLoadingLabel = this.$submit.data('progress-label'); 496 497 // setup 498 this.findControls(); 499 } 500
501 Form.prototype.findControls = function() { 502 var $controls = this.$el.find('input:not(.js-mkb-form-submit), select, textarea'); 503 504 this.controls = makeArray($controls).map(function(el) { 505 return { 506 id: el.getAttribute('name'), 507 el: el, 508 $el: $(el) 509 }; 510 }); 511 }; 512 513 Form.prototype.isLoading = function() { 514 return this._isLoading; 515 }; 516 517 Form.prototype.startLoading = function() { 518 this._isLoading = true; 519 520 this.disableControls(); 521 this.disableControl(this.$submit); 522 this.$submit.val(this.submitLoadingLabel); 523 this.$el.addClass('state--loading'); 524 525 this.$el.append('<div class="js-mkb-form-overlay mkb-form-overlay"></div>') 526 }; 527 528 Form.prototype.endLoading = function() { 529 this._isLoading = false; 530 531 this.enableControls(); 532 this.enableControl(this.$submit); 533 this.$submit.val(this.submitLabel); 534 535 this.$el.find('.js-mkb-form-overlay').remove(); 536 this.$el.removeClass('state--loading'); 537 }; 538 539 Form.prototype.disableControls = function() { 540 this.controls.forEach(function(control) { 541 this.disableControl(control.$el); 542 }.bind(this)); 543 544 this.quillEditors.forEach(function(quillEditor) { 545 quillEditor.enable(false); 546 }); 547 }; 548 549 Form.prototype.lock = function() { 550 // TODO: maybe use separate state var 551 this._isLoading = true; 552 553 this.disableControls(); 554 this.disableControl(this.$submit); 555 }; 556 557 Form.prototype.enableControls = function() { 558 this.controls.forEach(function(control) { 559 this.enableControl(control.$el); 560 }.bind(this)); 561 562 this.quillEditors.forEach(function(quillEditor) { 563 quillEditor.enable(true); 564 }); 565 }; 566 567 Form.prototype.disableControl = function($control) { 568 $control.attr('disabled', 'disabled'); 569 }; 570 571 Form.prototype.enableControl = function($control) { 572 $control.attr('disabled', false); 573 }; 574 575 Form.prototype.showMessages = function(messages, messagesType) { 576 messagesType = messagesType || 'error'; 577 578 this.clearMessages(); 579 580 this.$messagesEl 581 .addClass('mkb-form-messages--' + messagesType) 582 .html(messages); // TODO: handle array 583 584 $([document.documentElement, document.body]).animate({ 585 scrollTop: this.$messagesEl.offset().top - parseInt(settings['global_scroll_offset']['size'], 10) // TODO: scroll offset setting 586 }, 100); 587 }; 588 589 Form.prototype.clearMessages = function() { 590 this.$messagesEl.removeClass('mkb-form-messages--error mkb-form-messages--success').html('') 591 }; 592 593 Form.prototype.serialize = function() { 594 return $(this.el).serializeArray().reduce(function(store, kv) { 595 store[kv.name] = kv.value; 596 return store; 597 }, {}); 598 }; 599 600 /** 601 * Exports 602 */ 603 window.MinervaCommonUI = { 604 Form: Form, 605 setupQuillEditor: setupQuillEditor, 606 setupFileUpload: setupFileUpload 607 }; 608})(window.jQuery);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.