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