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