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