[BUGFIX] Mark form as changed after using the image cropper
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Resources / Private / TypeScript / ImageManipulation.ts
1 /*
2  * This file is part of the TYPO3 CMS project.
3  *
4  * It is free software; you can redistribute it and/or modify it under
5  * the terms of the GNU General Public License, either version 2
6  * of the License, or any later version.
7  *
8  * For the full copyright and license information, please read the
9  * LICENSE.txt file that was distributed with this source code.
10  *
11  * The TYPO3 project - inspiring people to share!
12  */
13
14 import * as $ from 'jquery';
15 import 'jquery-ui/draggable';
16 import 'jquery-ui/resizable';
17 import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
18 import Icons = require('./Icons');
19 import Modal = require('./Modal');
20 import ImagesLoaded = require('TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min');
21
22 interface Area {
23   x: number;
24   y: number;
25   height: number;
26   width: number;
27 }
28
29 interface Ratio {
30   id: string;
31   title: string;
32   value: number;
33 }
34
35 interface CropVariant {
36   title: string;
37   id: string;
38   selectedRatio: string;
39   cropArea?: Area;
40   focusArea?: Area;
41   coverAreas?: Area[];
42   allowedAspectRatios: {[key: string]: Ratio};
43 }
44
45 interface Offset {
46   left: number;
47   top: number;
48 }
49
50 interface CropperEvent {
51   x: number;
52   y: number;
53   width: number;
54   height: number;
55   rotate: number;
56   scaleX: number;
57   scaleY: number;
58 }
59
60 interface CropperImageData {
61   left: number;
62   top: number;
63   width: number;
64   height: number;
65   naturalWidth: number;
66   naturalHeight: number;
67   aspectRatio: number;
68   rotate: number;
69   scaleX: number;
70   scaleY: number;
71 }
72
73 /**
74  * Module: TYPO3/CMS/Backend/ImageManipulation
75  * Contains all logic for the image crop GUI including setting focusAreas
76  * @exports TYPO3/CMS/Backend/ImageManipulation
77  */
78 class ImageManipulation {
79   private trigger: JQuery;
80   private currentModal: JQuery;
81   private cropVariantTriggers: JQuery;
82   private activeCropVariantTrigger: JQuery;
83   private saveButton: JQuery;
84   private previewButton: JQuery;
85   private dismissButton: JQuery;
86   private resetButton: JQuery;
87   private aspectRatioTrigger: JQuery;
88   private cropperCanvas: JQuery;
89   private cropInfo: JQuery;
90   private cropImageContainerSelector: string = '#t3js-crop-image-container';
91   private imageOriginalSizeFactor: number;
92   private cropImageSelector: string = '#t3js-crop-image';
93   private coverAreaSelector: string = '.t3js-cropper-cover-area';
94   private cropInfoSelector: string = '.t3js-cropper-info-crop';
95   private focusAreaSelector: string = '#t3js-cropper-focus-area';
96   private focusArea: any;
97   private cropBox: JQuery;
98   private cropper: any;
99   private currentCropVariant: CropVariant;
100   private data: any;
101   private defaultFocusArea: Area = {
102     height: 1 / 3,
103     width: 1 / 3,
104     x: 0,
105     y: 0,
106   };
107   private defaultOpts: object = {
108     autoCrop: true,
109     autoCropArea: '0.7',
110     dragMode: 'crop',
111     guides: true,
112     responsive: true,
113     viewMode: 1,
114     zoomable: false,
115   };
116   private resizeTimeout: Number = 450;
117
118   /**
119    * @method isCropAreaEmpty
120    * @desc Checks if an area is set or pristine
121    * @param {Area} area - The area to check
122    * @return {boolean}
123    * @static
124    */
125   public static isEmptyArea(area: Area): boolean {
126     return $.isEmptyObject(area);
127   }
128
129   /**
130    * @method wait
131    * @desc window.setTimeout shim
132    * @param {Function} fn - The function to execute
133    * @param {number} ms - The time in [ms] to wait until execution
134    * @return {boolean}
135    * @public
136    * @static
137    */
138   public static wait(fn: () => void, ms: number): void {
139     window.setTimeout(fn, ms);
140   }
141
142   /**
143    * @method toCssPercent
144    * @desc Takes a number, and converts it to CSS percentage length
145    * @param {number} num - The number to convert
146    * @return {string}
147    * @public
148    * @static
149    */
150   public static toCssPercent(num: number): string {
151     return `${num * 100}%`;
152   }
153
154   /**
155    * @method serializeCropVariants
156    * @desc Serializes crop variants for persistence or preview
157    * @param {Object} cropVariants
158    * @returns string
159    */
160   private static serializeCropVariants(cropVariants: object): string {
161     const omitUnused: any = (key: any, value: any): any =>
162       (
163         key === 'id'
164         || key === 'title'
165         || key === 'allowedAspectRatios'
166         || key === 'coverAreas'
167       ) ? undefined : value;
168
169     return JSON.stringify(cropVariants, omitUnused);
170   }
171
172   constructor() {
173     // silence is golden
174     $(window).resize((): void => {
175       if (this.cropper) {
176         this.cropper.cropper('destroy');
177       }
178     });
179     this.resizeEnd((): void => {
180       if (this.cropper) {
181         this.init();
182       }
183     });
184   }
185
186   /**
187    * @method initializeTrigger
188    * @desc Assign a handler to .t3js-image-manipulation-trigger.
189    *       Show the modal and kick-off image manipulation
190    * @public
191    */
192   public initializeTrigger(): void {
193     const triggerHandler = (e: JQueryEventObject): void => {
194       e.preventDefault();
195       this.trigger = $(e.currentTarget);
196       this.show();
197     };
198     $('.t3js-image-manipulation-trigger').off('click').click(triggerHandler);
199   }
200
201   /**
202    * @method initializeCropperModal
203    * @desc Initialize the cropper modal and dispatch the cropper init
204    * @private
205    */
206   private initializeCropperModal(): void {
207     const image: JQuery = this.currentModal.find(this.cropImageSelector);
208     ImagesLoaded(image as any, (): void => {
209       setTimeout(
210         (): void => {
211           this.init();
212         },
213         100
214       );
215     });
216   }
217
218   /**
219    * @method show
220    * @desc Load the image and setup the modal UI
221    * @private
222    */
223   private show(): void {
224     const modalTitle: string = this.trigger.data('modalTitle');
225     const buttonPreviewText: string = this.trigger.data('buttonPreviewText');
226     const buttonDismissText: string = this.trigger.data('buttonDismissText');
227     const buttonSaveText: string = this.trigger.data('buttonSaveText');
228     const imageUri: string = this.trigger.data('url');
229     const payload: object = this.trigger.data('payload');
230     const initCropperModal: ()  => void = this.initializeCropperModal.bind(this);
231
232     Icons.getIcon('spinner-circle', Icons.sizes.default, null, null, Icons.markupIdentifiers.inline).done((icon: string): void => {
233       /**
234        * Open modal with image to crop
235        */
236       this.currentModal = Modal.advanced({
237         additionalCssClasses: ['modal-image-manipulation'],
238         buttons: [
239           {
240             btnClass: 'btn-default pull-left',
241             dataAttributes: {
242               method: 'preview',
243             },
244             icon: 'actions-view',
245             text: buttonPreviewText,
246           },
247           {
248             btnClass: 'btn-default',
249             dataAttributes: {
250               method: 'dismiss',
251             },
252             icon: 'actions-close',
253             text: buttonDismissText,
254           },
255           {
256             btnClass: 'btn-primary',
257             dataAttributes: {
258               method: 'save',
259             },
260             icon: 'actions-document-save',
261             text: buttonSaveText,
262           },
263         ],
264         callback: (currentModal: JQuery): void => {
265           $.post({
266             url: imageUri,
267             data: payload
268           }).done((response: string): void => {
269             initCropperModal();
270             currentModal.find('.t3js-modal-body').append(response).addClass('cropper');
271           });
272         },
273         content: $('<div class="modal-loading">').append(icon),
274         size: Modal.sizes.full,
275         style: Modal.styles.dark,
276         title: modalTitle,
277       });
278
279       this.currentModal.on('hide.bs.modal', (e: JQueryEventObject): void => {
280         this.destroy();
281       });
282       // do not dismiss the modal when clicking beside it to avoid data loss
283       this.currentModal.data('bs.modal').options.backdrop = 'static';
284     });
285   }
286
287   /**
288    * @method init
289    * @desc Initializes the cropper UI and sets up all the event indings for the UI
290    * @private
291    */
292   private init(): void {
293     const image: JQuery = this.currentModal.find(this.cropImageSelector);
294     const imageHeight: number = $(image).height();
295     const imageWidth: number = $(image).width();
296     const data: string = this.trigger.attr('data-crop-variants');
297
298     if (!data) {
299       throw new TypeError('ImageManipulation: No cropVariants data found for image');
300     }
301
302     // if we have data already set we assume an internal reinit eg. after resizing
303     this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
304     // initialize our class members
305     this.currentModal.find(this.cropImageContainerSelector).css({height: imageHeight, width: imageWidth});
306     this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
307     this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
308     this.cropInfo = this.currentModal.find(this.cropInfoSelector);
309     this.saveButton = this.currentModal.find('[data-method=save]');
310     this.previewButton = this.currentModal.find('[data-method=preview]');
311     this.dismissButton = this.currentModal.find('[data-method=dismiss]');
312     this.resetButton = this.currentModal.find('[data-method=reset]');
313     this.cropperCanvas = this.currentModal.find('#js-crop-canvas');
314     this.aspectRatioTrigger = this.currentModal.find('[data-method=setAspectRatio]');
315     this.currentCropVariant = (this as any).data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
316
317     /**
318      * Assign EventListener to cropVariantTriggers
319      */
320     this.cropVariantTriggers.off('click').on('click', (e: JQueryEventObject): void => {
321
322       /**
323        * Is the current cropVariantTrigger is active, bail out.
324        * Bootstrap doesn't provide this functionality when collapsing the Collaps panels
325        */
326       if ($(e.currentTarget).hasClass('is-active')) {
327         e.stopPropagation();
328         e.preventDefault();
329         return;
330       }
331
332       this.activeCropVariantTrigger.removeClass('is-active');
333       $(e.currentTarget).addClass('is-active');
334       this.activeCropVariantTrigger = $(e.currentTarget);
335       const cropVariant: CropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
336       const imageData: CropperImageData = this.cropper.cropper('getImageData');
337       cropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
338       this.currentCropVariant = $.extend(true, {}, cropVariant);
339       this.update(cropVariant);
340     });
341
342     /**
343      * Assign EventListener to aspectRatioTrigger
344      */
345     this.aspectRatioTrigger.off('click').on('click', (e: JQueryEventObject): void => {
346       const ratioId: string = $(e.currentTarget).attr('data-option');
347       const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
348       const ratio: Ratio = temp.allowedAspectRatios[ratioId];
349       this.setAspectRatio(ratio);
350       // set data explicitly or setAspectRatio upscales the crop
351       this.setCropArea(temp.cropArea);
352       this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
353       this.update(this.currentCropVariant);
354     });
355
356     /**
357      * Assign EventListener to saveButton
358      */
359     this.saveButton.off('click').on('click', (): void => {
360       this.save(this.data);
361     });
362
363     /**
364      * Assign EventListener to previewButton if preview url exists
365      */
366     if (this.trigger.attr('data-preview-url')) {
367       this.previewButton.off('click').on('click', (): void => {
368         this.openPreview(this.data);
369       });
370     } else {
371       this.previewButton.hide();
372     }
373
374     /**
375      * Assign EventListener to dismissButton
376      */
377     this.dismissButton.off('click').on('click', (): void => {
378       this.currentModal.modal('hide');
379     });
380
381     /**
382      * Assign EventListener to resetButton
383      */
384     this.resetButton.off('click').on('click', (e: JQueryEventObject): void => {
385       const imageData: CropperImageData = this.cropper.cropper('getImageData');
386       const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
387       e.preventDefault();
388       e.stopPropagation();
389       if (!resetCropVariantString) {
390         throw new TypeError('TYPO3 Cropper: No cropVariant data attribute found on reset element.');
391       }
392       const resetCropVariant: CropVariant = JSON.parse(resetCropVariantString);
393       const absoluteCropArea: Area = this.convertRelativeToAbsoluteCropArea(resetCropVariant.cropArea, imageData);
394       this.currentCropVariant = $.extend(true, {}, resetCropVariant, {cropArea: absoluteCropArea});
395       this.update(this.currentCropVariant);
396     });
397
398     // if we start without an cropArea, maximize the cropper
399     if (ImageManipulation.isEmptyArea(this.currentCropVariant.cropArea)) {
400       this.defaultOpts = $.extend({
401         autoCropArea: 1,
402       },                          this.defaultOpts);
403     }
404
405     /**
406      * Initialise the cropper
407      *
408      * Note: We use the extraneous jQuery object here, as CropperJS won't work inside the <iframe>
409      * The top.require is now inlined @see ImageManipulationElemen.php:143
410      * TODO: Find a better solution for cross iframe communications
411      */
412     this.cropper = (top.$(image) as any).cropper($.extend(this.defaultOpts, {
413       built: this.cropBuiltHandler,
414       crop: this.cropMoveHandler,
415       cropend: this.cropEndHandler,
416       cropstart: this.cropStartHandler,
417       data: this.currentCropVariant.cropArea,
418     }));
419   }
420
421   /**
422    * @method cropBuiltHandler
423    * @desc Internal cropper handler. Called when the cropper has been instantiated
424    * @private
425    */
426   private cropBuiltHandler = (): void => {
427     const imageData: CropperImageData = this.cropper.cropper('getImageData');
428     const image: JQuery = this.currentModal.find(this.cropImageSelector);
429
430     this.imageOriginalSizeFactor = image.data('originalWidth') / imageData.naturalWidth;
431
432     // iterate over the crop variants and set up their respective preview
433     this.cropVariantTriggers.each((index: number, elem: Element): void => {
434       const cropVariantId: string = $(elem).attr('data-crop-variant-id');
435       const cropArea: Area = this.convertRelativeToAbsoluteCropArea(
436         this.data[cropVariantId].cropArea,
437         imageData,
438       );
439       const variant: CropVariant = $.extend(true, {}, this.data[cropVariantId], {cropArea});
440       this.updatePreviewThumbnail(variant, $(elem));
441     });
442
443     this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
444       this.currentCropVariant.cropArea,
445       imageData,
446     );
447     // can't use .t3js-* as selector because it is an extraneous selector
448     this.cropBox = this.currentModal.find('.cropper-crop-box');
449
450     this.setCropArea(this.currentCropVariant.cropArea);
451
452     // check if new cropVariant has coverAreas
453     if (this.currentCropVariant.coverAreas) {
454       // init or reinit focusArea
455       this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
456     }
457     // check if new cropVariant has focusArea
458     if (this.currentCropVariant.focusArea) {
459       // init or reinit focusArea
460       if (ImageManipulation.isEmptyArea(this.currentCropVariant.focusArea)) {
461         // if an empty focusArea is set initialise it with the default
462         this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
463       }
464       this.initFocusArea(this.cropBox);
465       this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
466     }
467
468     if (this.currentCropVariant.selectedRatio) {
469       this.setAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
470       // set data explicitly or setAspectRatio up-scales the crop
471       this.setCropArea(this.currentCropVariant.cropArea);
472       this.currentModal.find(`[data-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
473     }
474
475     this.cropperCanvas.addClass('is-visible');
476   }
477
478   /**
479    * @method cropMoveHandler
480    * @desc Internal cropper handler. Called when the cropping area is moving
481    * @private
482    */
483   private cropMoveHandler = (e: CropperEvent): void => {
484     this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
485       height: Math.floor(e.height),
486       width: Math.floor(e.width),
487       x: Math.floor(e.x),
488       y: Math.floor(e.y),
489     });
490     this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
491     this.updateCropVariantData(this.currentCropVariant);
492     const naturalWidth: number = Math.round(this.currentCropVariant.cropArea.width * this.imageOriginalSizeFactor);
493     const naturalHeight: number = Math.round(this.currentCropVariant.cropArea.height * this.imageOriginalSizeFactor);
494     this.cropInfo.text(`${naturalWidth}×${naturalHeight} px`);
495   }
496
497   /**
498    * @method cropStartHandler
499    * @desc Internal cropper handler. Called when the cropping starts moving
500    * @private
501    */
502   private cropStartHandler = (): void => {
503     if (this.currentCropVariant.focusArea) {
504       this.focusArea.draggable('option', 'disabled', true);
505       this.focusArea.resizable('option', 'disabled', true);
506     }
507   }
508
509   /**
510    * @method cropEndHandler
511    * @desc Internal cropper handler. Called when the cropping ends moving
512    * @private
513    */
514   private cropEndHandler = (): void => {
515     if (this.currentCropVariant.focusArea) {
516       this.focusArea.draggable('option', 'disabled', false);
517       this.focusArea.resizable('option', 'disabled', false);
518     }
519   }
520
521   /**
522    * @method update
523    * @desc Update current cropArea position and size when changing cropVariants
524    * @param {CropVariant} cropVariant - The new cropVariant to update the UI with
525    */
526   private update(cropVariant: CropVariant): void {
527     const temp: CropVariant = $.extend(true, {}, cropVariant);
528     const selectedRatio: Ratio = cropVariant.allowedAspectRatios[cropVariant.selectedRatio];
529     this.currentModal.find('[data-option]').removeClass('active');
530     this.currentModal.find(`[data-option="${cropVariant.selectedRatio}"]`).addClass('active');
531     /**
532      * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
533      */
534     this.setAspectRatio(selectedRatio);
535     this.setCropArea(temp.cropArea);
536     this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
537     this.cropBox.find(this.coverAreaSelector).remove();
538
539     // if the current container has a focus area element, deregister and cleanup prior to initialization
540     if (this.cropBox.has(this.focusAreaSelector).length) {
541       this.focusArea.resizable('destroy').draggable('destroy');
542       this.focusArea.remove();
543     }
544
545     // check if new cropVariant has focusArea
546     if (cropVariant.focusArea) {
547       // init or reinit focusArea
548       if (ImageManipulation.isEmptyArea(cropVariant.focusArea)) {
549         this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
550       }
551       this.initFocusArea(this.cropBox);
552       this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
553     }
554
555     // check if new cropVariant has coverAreas
556     if (cropVariant.coverAreas) {
557       // init or reinit focusArea
558       this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
559     }
560     this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
561   }
562
563   /**
564    * @method initFocusArea
565    * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
566    * @param {JQuery} container
567    * @private
568    */
569   private initFocusArea(container: JQuery): void {
570     this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
571     container.append(this.focusArea);
572     this.focusArea
573         .draggable({
574           containment: container,
575           create: (): void => {
576             this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
577           },
578           drag: (): void => {
579             const {left, top}: Offset = container.offset();
580             const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
581             const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
582
583             focusArea.x = (fLeft - left) / container.width();
584             focusArea.y = (fTop - top) / container.height();
585             this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
586             if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
587               this.focusArea.addClass('has-nodrop');
588             } else {
589               this.focusArea.removeClass('has-nodrop');
590             }
591           },
592           revert: (): boolean => {
593             const revertDelay = 250;
594             const {left, top}: Offset = container.offset();
595             const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
596             const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
597
598             if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
599               this.focusArea.removeClass('has-nodrop');
600               ImageManipulation.wait((): void => {
601                 focusArea.x = (fLeft - left) / container.width();
602                 focusArea.y = (fTop - top) / container.height();
603                 this.updateCropVariantData(this.currentCropVariant);
604               },                     revertDelay);
605               return true;
606             }
607             return false;
608           },
609           revertDuration: 200,
610           stop: (): void => {
611             const {left, top}: Offset = container.offset();
612             const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
613             const {focusArea}: {focusArea?: Area} = this.currentCropVariant;
614
615             focusArea.x = (fLeft - left) / container.width();
616             focusArea.y = (fTop - top) / container.height();
617
618             this.scaleAndMoveFocusArea(focusArea);
619           },
620         })
621         .resizable({
622           containment: container,
623           handles: 'all',
624           resize: (): void => {
625             const {left, top}: Offset = container.offset();
626             const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
627             const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
628
629             focusArea.height = this.focusArea.height() / container.height();
630             focusArea.width = this.focusArea.width() / container.width();
631             focusArea.x = (fLeft - left) / container.width();
632             focusArea.y = (fTop - top) / container.height();
633             this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
634
635             if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
636               this.focusArea.addClass('has-nodrop');
637             } else {
638               this.focusArea.removeClass('has-nodrop');
639             }
640
641           },
642           stop: (event: any, ui: any): void => {
643             const revertDelay = 250;
644             const {left, top}: Offset = container.offset();
645             const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
646             const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
647
648             if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
649               ui.element.animate($.extend(ui.originalPosition, ui.originalSize), revertDelay, (): void => {
650
651                 focusArea.height = this.focusArea.height() / container.height();
652                 focusArea.height = this.focusArea.height() / container.height();
653                 focusArea.width = this.focusArea.width() / container.width();
654                 focusArea.x = (fLeft - left) / container.width();
655                 focusArea.y = (fTop - top) / container.height();
656
657                 this.scaleAndMoveFocusArea(focusArea);
658                 this.focusArea.removeClass('has-nodrop');
659               });
660             } else {
661               this.scaleAndMoveFocusArea(focusArea);
662             }
663           },
664         });
665   }
666
667   /**
668    * @method initCoverAreas
669    * @desc Initialise cover areas inside the cropper container
670    * @param {JQuery} container - The container element to append the cover areas
671    * @param {Array<Area>} coverAreas - An array of areas to construxt the cover area elements from
672    */
673   private initCoverAreas(container: JQuery, coverAreas: Area[]): void {
674     coverAreas.forEach((coverArea: Area): void => {
675       const coverAreaCanvas: JQuery = $('<div class="cropper-cover-area t3js-cropper-cover-area"></div>');
676       container.append(coverAreaCanvas);
677       coverAreaCanvas.css({
678         height: ImageManipulation.toCssPercent(coverArea.height),
679         left: ImageManipulation.toCssPercent(coverArea.x),
680         top: ImageManipulation.toCssPercent(coverArea.y),
681         width: ImageManipulation.toCssPercent(coverArea.width),
682       });
683     });
684   }
685
686   /**
687    * @method updatePreviewThumbnail
688    * @desc Sync the croping (and focus area) to the preview thumbnail
689    * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
690    * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
691    * @private
692    */
693   private updatePreviewThumbnail(cropVariant: CropVariant, cropVariantTrigger: JQuery): void {
694     let styles: any;
695     const cropperPreviewThumbnailCrop: JQuery =
696       cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
697     const cropperPreviewThumbnailImage: JQuery =
698       cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
699     const cropperPreviewThumbnailFocus: JQuery =
700       cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
701     const imageData: CropperImageData = this.cropper.cropper('getImageData');
702
703     // update the position/dimension of the crop area in the preview
704     cropperPreviewThumbnailCrop.css({
705       height: ImageManipulation.toCssPercent(cropVariant.cropArea.height / imageData.naturalHeight),
706       left: ImageManipulation.toCssPercent(cropVariant.cropArea.x / imageData.naturalWidth),
707       top: ImageManipulation.toCssPercent(cropVariant.cropArea.y / imageData.naturalHeight),
708       width: ImageManipulation.toCssPercent(cropVariant.cropArea.width / imageData.naturalWidth),
709     });
710
711     // show and update focusArea in the preview only if we really have one configured
712     if (cropVariant.focusArea) {
713       cropperPreviewThumbnailFocus.css({
714         height: ImageManipulation.toCssPercent(cropVariant.focusArea.height),
715         left: ImageManipulation.toCssPercent(cropVariant.focusArea.x),
716         top: ImageManipulation.toCssPercent(cropVariant.focusArea.y),
717         width: ImageManipulation.toCssPercent(cropVariant.focusArea.width),
718       });
719     }
720
721     // destruct the preview container's CSS properties
722     styles = cropperPreviewThumbnailCrop.css([
723       'width', 'height', 'left', 'top',
724     ]);
725
726     /**
727      * Apply negative margins on the previewThumbnailImage to make the illusion of an offset
728      */
729     cropperPreviewThumbnailImage.css({
730       height: `${parseFloat(styles.height) * (1 / (cropVariant.cropArea.height / imageData.naturalHeight))}px`,
731       margin: `${-1 * parseFloat(styles.left)}px`,
732       marginTop: `${-1 * parseFloat(styles.top)}px`,
733       width: `${parseFloat(styles.width) * (1 / (cropVariant.cropArea.width / imageData.naturalWidth))}px`,
734     });
735   }
736
737   /**
738    * @method scaleAndMoveFocusArea
739    * @desc Calculation logic for moving the focus area given the
740    *       specified constrains of a crop and an optional cover area
741    * @param {Area} focusArea - The translation data
742    */
743   private scaleAndMoveFocusArea(focusArea: Area): void {
744     this.focusArea.css({
745       height: ImageManipulation.toCssPercent(focusArea.height),
746       left: ImageManipulation.toCssPercent(focusArea.x),
747       top: ImageManipulation.toCssPercent(focusArea.y),
748       width: ImageManipulation.toCssPercent(focusArea.width),
749     });
750     this.currentCropVariant.focusArea = focusArea;
751     this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
752     this.updateCropVariantData(this.currentCropVariant);
753   }
754
755   /**
756    * @method updateCropVariantData
757    * @desc Immutably updates the currently selected cropVariant data
758    * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
759    * @private
760    */
761   private updateCropVariantData(currentCropVariant: CropVariant): void {
762     const imageData: CropperImageData = this.cropper.cropper('getImageData');
763     const absoluteCropArea: Area = this.convertAbsoluteToRelativeCropArea(currentCropVariant.cropArea, imageData);
764     this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, {cropArea: absoluteCropArea});
765   }
766
767   /**
768    * @method setAspectRatio
769    * @desc Sets the cropper to a specific ratio
770    * @param {ratio} ratio - The ratio value to apply
771    * @private
772    */
773   private setAspectRatio(ratio: Ratio): void {
774     this.cropper.cropper('setAspectRatio', ratio.value);
775   }
776
777   /**
778    * @method setCropArea
779    * @desc Sets the cropper to a specific crop area
780    * @param {cropArea} cropArea - The crop area to apply
781    * @private
782    */
783   private setCropArea(cropArea: Area): void {
784     const currentRatio: Ratio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
785     if (currentRatio.value === 0) {
786       this.cropper.cropper('setData', {
787         height: cropArea.height,
788         width: cropArea.width,
789         x: cropArea.x,
790         y: cropArea.y,
791       });
792     } else {
793       this.cropper.cropper('setData', {
794         height: cropArea.height,
795         x: cropArea.x,
796         y: cropArea.y,
797       });
798     }
799   }
800
801   /**
802    * @method checkFocusAndCoverAreas
803    * @desc Checks is one focus area and one or more cover areas overlap
804    * @param focusArea
805    * @param coverAreas
806    * @return {boolean}
807    */
808   private checkFocusAndCoverAreasCollision(focusArea: Area, coverAreas: Area[]): boolean {
809     if (!coverAreas) {
810       return false;
811     }
812     return coverAreas
813       .some((coverArea: Area): boolean => {
814         // noinspection OverlyComplexBooleanExpressionJS
815         return (focusArea.x < coverArea.x + coverArea.width &&
816            focusArea.x + focusArea.width > coverArea.x &&
817             focusArea.y < coverArea.y + coverArea.height &&
818            focusArea.height + focusArea.y > coverArea.y);
819       });
820   }
821
822   /**
823    * @method convertAbsoluteToRelativeCropArea
824    * @desc Converts a crop area from absolute pixel-based into relative length values
825    * @param {Area} cropArea - The crop area to convert from
826    * @param {CropperImageData} imageData - The image data
827    * @return {Area}
828    */
829   private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: CropperImageData): Area {
830     const {height, width, x, y}: Area = cropArea;
831     return {
832       height: height / imageData.naturalHeight,
833       width: width / imageData.naturalWidth,
834       x: x / imageData.naturalWidth,
835       y: y / imageData.naturalHeight,
836     };
837   }
838
839   /**
840    * @method convertRelativeToAbsoluteCropArea
841    * @desc Converts a crop area from relative into absolute pixel-based length values
842    * @param {Area} cropArea - The crop area to convert from
843    * @param {CropperImageData} imageData - The image data
844    * @return {{height: number, width: number, x: number, y: number}}
845    */
846   private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: CropperImageData): Area {
847     const {height, width, x, y}: Area = cropArea;
848     return {
849       height: height * imageData.naturalHeight,
850       width: width * imageData.naturalWidth,
851       x: x * imageData.naturalWidth,
852       y: y * imageData.naturalHeight,
853     };
854   }
855
856   /**
857    * @method setPreviewImages
858    * @desc Updates the preview images in the editing section with the respective crop variants
859    * @param {Object} data - The internal crop variants state
860    */
861   private setPreviewImages(data: {[key: string]: CropVariant}): void {
862     const $image: any = this.cropper;
863     const imageData: CropperImageData = $image.cropper('getImageData');
864
865     // iterate over the crop variants and set up their respective preview
866     Object.keys(data).forEach((cropVariantId: string) => {
867       const cropVariant: CropVariant = data[cropVariantId];
868       const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
869
870       const $preview: JQuery = this.trigger
871                                    .closest('.form-group')
872                                    .find(`.t3js-image-manipulation-preview[data-crop-variant-id="${cropVariantId}"]`);
873       const $previewSelectedRatio: JQuery = this.trigger
874                                                 .closest('.form-group')
875                                                 .find(`.t3js-image-manipulation-selected-ratio[data-crop-variant-id="${cropVariantId}"]`); // tslint:disable-line:max-line-length
876
877       if ($preview.length === 0) {
878         return;
879       }
880
881       let previewWidth: number = $preview.width();
882       let previewHeight: number = $preview.data('preview-height');
883
884       // adjust aspect ratio of preview width/height
885       const aspectRatio: number = cropData.width / cropData.height;
886       const tmpHeight: number = previewWidth / aspectRatio;
887       if (tmpHeight > previewHeight) {
888         previewWidth = previewHeight * aspectRatio;
889       } else {
890         previewHeight = tmpHeight;
891       }
892       // preview should never be up-scaled
893       if (previewWidth > cropData.width) {
894         previewWidth = cropData.width;
895         previewHeight = cropData.height;
896       }
897
898       const ratio: number = previewWidth / cropData.width;
899       const $viewBox: JQuery = $('<div />').html('<img src="' + $image.attr('src') + '">');
900       const $ratioTitleText: JQuery = this.currentModal.find(`.t3-js-ratio-title[data-ratio-id="${cropVariant.id}${cropVariant.selectedRatio}"]`); // tslint:disable-line:max-line-length
901       $previewSelectedRatio.text($ratioTitleText.text());
902       $viewBox.addClass('cropper-preview-container');
903       $preview.empty().append($viewBox);
904       $viewBox.wrap('<span class="thumbnail thumbnail-status"></span>');
905
906       $viewBox.width(previewWidth).height(previewHeight).find('img').css({
907         height: imageData.naturalHeight * ratio,
908         left: -cropData.x * ratio,
909         top: -cropData.y * ratio,
910         width: imageData.naturalWidth * ratio,
911       });
912     });
913   }
914
915   /**
916    * @method openPreview
917    * @desc Opens a preview view with the crop variants
918    * @param {object} data - The whole data object containing all the cropVariants
919    * @private
920    */
921   private openPreview(data: object): void {
922     const cropVariants: string = ImageManipulation.serializeCropVariants(data);
923     let previewUrl: string = this.trigger.attr('data-preview-url');
924     previewUrl = previewUrl + '&cropVariants=' + encodeURIComponent(cropVariants);
925     window.open(previewUrl, 'TYPO3ImageManipulationPreview');
926   }
927
928   /**
929    * @method save
930    * @desc Saves the edited cropVariants to a hidden field
931    * @param {object} data - The whole data object containing all the cropVariants
932    * @private
933    */
934   private save(data: {[key: string]: CropVariant}): void {
935     const cropVariants: string = ImageManipulation.serializeCropVariants(data);
936     const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
937     this.trigger.attr('data-crop-variants', JSON.stringify(data));
938     this.setPreviewImages(data);
939     hiddenField.val(cropVariants);
940     FormEngineValidation.markFieldAsChanged(hiddenField);
941     this.currentModal.modal('hide');
942   }
943
944   /**
945    * @method destroy
946    * @desc Destroy the ImageManipulation including cropper and alike
947    * @private
948    */
949   private destroy(): void {
950     if (this.currentModal) {
951       if (typeof this.cropper !== 'undefined' && this.cropper !== null) {
952         this.cropper.cropper('destroy');
953       }
954       this.cropper = null;
955       this.currentModal = null;
956       this.data = null;
957     }
958   }
959
960   /**
961    * @method resizeEnd
962    * @desc Calls a function when the cropper has been resized
963    * @param {Function} fn - The function to call on resize completion
964    * @private
965    */
966   private resizeEnd(fn: () => void): void {
967     let timer: number;
968     $(window).on('resize', (): void => {
969       clearTimeout(timer);
970       timer = setTimeout((): void => {
971         fn();
972       },                 this.resizeTimeout);
973     });
974   }
975 }
976
977 export = new ImageManipulation();