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