ImageManipulation.ts 34.3 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

14
import $ from 'jquery';
15
16
import 'jquery-ui/draggable';
import 'jquery-ui/resizable';
17
import {AjaxResponse} from 'TYPO3/CMS/Core/Ajax/AjaxResponse';
18
import FormEngineValidation = require('TYPO3/CMS/Backend/FormEngineValidation');
19
import AjaxRequest = require('TYPO3/CMS/Core/Ajax/AjaxRequest');
20
import Cropper from 'cropperjs';
21
import ImagesLoaded = require('imagesloaded');
22
import Icons = require('./Icons');
23
import Modal = require('./Modal');
24
import ThrottleEvent from 'TYPO3/CMS/Core/Event/ThrottleEvent';
25

26
interface Area {
27
28
29
30
31
32
  x: number;
  y: number;
  height: number;
  width: number;
}

33
interface Ratio {
34
35
36
37
38
  id: string;
  title: string;
  value: number;
}

39
interface CropVariant {
40
41
42
43
44
45
  title: string;
  id: string;
  selectedRatio: string;
  cropArea?: Area;
  focusArea?: Area;
  coverAreas?: Area[];
46
  allowedAspectRatios: {[key: string]: Ratio};
47
48
}

49
interface Offset {
50
51
  left: number;
  top: number;
52
}
53

54
55
interface CropperEvent extends CustomEvent {
  detail: Cropper.Data;
56
57
58
59
60
61
62
63
}

/**
 * Module: TYPO3/CMS/Backend/ImageManipulation
 * Contains all logic for the image crop GUI including setting focusAreas
 * @exports TYPO3/CMS/Backend/ImageManipulation
 */
class ImageManipulation {
64
  private initialized: boolean = false;
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
  private trigger: JQuery;
  private currentModal: JQuery;
  private cropVariantTriggers: JQuery;
  private activeCropVariantTrigger: JQuery;
  private saveButton: JQuery;
  private previewButton: JQuery;
  private dismissButton: JQuery;
  private resetButton: JQuery;
  private aspectRatioTrigger: JQuery;
  private cropInfo: JQuery;
  private cropImageContainerSelector: string = '#t3js-crop-image-container';
  private imageOriginalSizeFactor: number;
  private cropImageSelector: string = '#t3js-crop-image';
  private coverAreaSelector: string = '.t3js-cropper-cover-area';
  private cropInfoSelector: string = '.t3js-cropper-info-crop';
  private focusAreaSelector: string = '#t3js-cropper-focus-area';
  private focusArea: any;
  private cropBox: JQuery;
83
  private cropper: Cropper;
84
85
86
87
88
89
90
91
  private currentCropVariant: CropVariant;
  private data: any;
  private defaultFocusArea: Area = {
    height: 1 / 3,
    width: 1 / 3,
    x: 0,
    y: 0,
  };
92
  private defaultOpts: Cropper.Options = {
93
    autoCrop: true,
94
    autoCropArea: 0.7,
95
96
97
98
99
    dragMode: 'crop',
    guides: true,
    responsive: true,
    viewMode: 1,
    zoomable: false,
100
    checkCrossOrigin: false,
101
  };
102
  private resizeTimeout: number = 450;
103

104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
  /**
   * @method isCropAreaEmpty
   * @desc Checks if an area is set or pristine
   * @param {Area} area - The area to check
   * @return {boolean}
   * @static
   */
  public static isEmptyArea(area: Area): boolean {
    return $.isEmptyObject(area);
  }

  /**
   * @method wait
   * @desc window.setTimeout shim
   * @param {Function} fn - The function to execute
   * @param {number} ms - The time in [ms] to wait until execution
   * @return {boolean}
   * @public
   * @static
   */
124
  public static wait(fn: () => void, ms: number): void {
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
    window.setTimeout(fn, ms);
  }

  /**
   * @method toCssPercent
   * @desc Takes a number, and converts it to CSS percentage length
   * @param {number} num - The number to convert
   * @return {string}
   * @public
   * @static
   */
  public static toCssPercent(num: number): string {
    return `${num * 100}%`;
  }

  /**
   * @method serializeCropVariants
   * @desc Serializes crop variants for persistence or preview
   * @param {Object} cropVariants
   * @returns string
   */
146
  private static serializeCropVariants(cropVariants: object): string {
147
148
    const omitUnused: any = (key: any, value: any) => {
      return (
149
150
151
152
153
        key === 'id'
        || key === 'title'
        || key === 'allowedAspectRatios'
        || key === 'coverAreas'
      ) ? undefined : value;
154
    };
155
156
157
158
159

    return JSON.stringify(cropVariants, omitUnused);
  }

  constructor() {
160
    // silence is golden
161
    $(window).on('resize', (): void => {
162
      if (this.cropper) {
163
        this.cropper.destroy();
164
165
      }
    });
166
    new ThrottleEvent('resize', (): void => {
167
168
169
      if (this.cropper) {
        this.init();
      }
170
    }, this.resizeTimeout).bindTo(window)
171
172
173
174
175
176
177
178
179
  }

  /**
   * @method initializeTrigger
   * @desc Assign a handler to .t3js-image-manipulation-trigger.
   *       Show the modal and kick-off image manipulation
   * @public
   */
  public initializeTrigger(): void {
180
    const triggerHandler = (e: JQueryEventObject): void => {
181
182
183
184
      e.preventDefault();
      this.trigger = $(e.currentTarget);
      this.show();
    };
185
    $('.t3js-image-manipulation-trigger').off('click').on('click', triggerHandler);
186
187
188
  }

  /**
189
190
191
   * @method initializeCropperModal
   * @desc Initialize the cropper modal and dispatch the cropper init
   * @private
192
193
   */
  private initializeCropperModal(): void {
194
    const image: JQuery = this.currentModal.find(this.cropImageSelector);
195
196
    ImagesLoaded(image.get(0), (): void => {
      this.init();
197
198
199
    });
  }

200
201
202
203
204
  /**
   * @method show
   * @desc Load the image and setup the modal UI
   * @private
   */
205
206
  private show(): void {
    const modalTitle: string = this.trigger.data('modalTitle');
207
208
209
    const buttonPreviewText: string = this.trigger.data('buttonPreviewText');
    const buttonDismissText: string = this.trigger.data('buttonDismissText');
    const buttonSaveText: string = this.trigger.data('buttonSaveText');
210
    const imageUri: string = this.trigger.data('url');
211
    const payload: object = this.trigger.data('payload');
212

213
    Icons.getIcon('spinner-circle', Icons.sizes.default, null, null, Icons.markupIdentifiers.inline).then((icon: string): void => {
214
215
216
217
218
219
220
221
222
223
224
225
226
      /**
       * Open modal with image to crop
       */
      this.currentModal = Modal.advanced({
        additionalCssClasses: ['modal-image-manipulation'],
        buttons: [
          {
            btnClass: 'btn-default pull-left',
            dataAttributes: {
              method: 'preview',
            },
            icon: 'actions-view',
            text: buttonPreviewText,
227
          },
228
229
230
231
232
233
234
          {
            btnClass: 'btn-default',
            dataAttributes: {
              method: 'dismiss',
            },
            icon: 'actions-close',
            text: buttonDismissText,
235
          },
236
237
238
239
240
241
242
          {
            btnClass: 'btn-primary',
            dataAttributes: {
              method: 'save',
            },
            icon: 'actions-document-save',
            text: buttonSaveText,
243
          },
244
        ],
245
        content: $('<div class="modal-loading">').append(icon),
246
247
248
249
250
        size: Modal.sizes.full,
        style: Modal.styles.dark,
        title: modalTitle,
      });

251
252
253
254
255
256
257
      this.currentModal.on('shown.bs.modal', (): void => {
        new AjaxRequest(imageUri).post(payload).then(async (response: AjaxResponse): Promise<void> => {
          this.currentModal.find('.t3js-modal-body').append(await response.resolve()).addClass('cropper');
          this.initializeCropperModal();
        });
      });

258
      this.currentModal.on('hide.bs.modal', (): void => {
259
260
261
262
        this.destroy();
      });
      // do not dismiss the modal when clicking beside it to avoid data loss
      this.currentModal.data('bs.modal').options.backdrop = 'static';
263
    });
264
265
  }

266
267
  /**
   * @method init
268
   * @desc Initializes the cropper UI and sets up all the event bindings for the UI
269
270
   * @private
   */
271
  private init(): void {
272
    const image: JQuery = this.currentModal.find(this.cropImageSelector);
273
274
275
276
277
278
279
280
    const imageHeight: number = $(image).height();
    const imageWidth: number = $(image).width();
    const data: string = this.trigger.attr('data-crop-variants');

    if (!data) {
      throw new TypeError('ImageManipulation: No cropVariants data found for image');
    }

281
    // if we have data already set we assume an internal reinit eg. after resizing
282
    this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
283
    // initialize our class members
284
    this.currentModal.find(this.cropImageContainerSelector).css({height: imageHeight, width: imageWidth});
285

286
287
288
289
290
291
292
293
    this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
    this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
    this.cropInfo = this.currentModal.find(this.cropInfoSelector);
    this.saveButton = this.currentModal.find('[data-method=save]');
    this.previewButton = this.currentModal.find('[data-method=preview]');
    this.dismissButton = this.currentModal.find('[data-method=dismiss]');
    this.resetButton = this.currentModal.find('[data-method=reset]');
    this.aspectRatioTrigger = this.currentModal.find('[data-method=setAspectRatio]');
294
    this.currentCropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
295
296
297
298

    /**
     * Assign EventListener to cropVariantTriggers
     */
299
    this.cropVariantTriggers.off('click').on('click', (e: JQueryEventObject): void => {
300
301
302

      /**
       * Is the current cropVariantTrigger is active, bail out.
303
       * Bootstrap doesn't provide this functionality when collapsing the Collapse panels
304
305
306
307
308
309
310
311
312
313
       */
      if ($(e.currentTarget).hasClass('is-active')) {
        e.stopPropagation();
        e.preventDefault();
        return;
      }

      this.activeCropVariantTrigger.removeClass('is-active');
      $(e.currentTarget).addClass('is-active');
      this.activeCropVariantTrigger = $(e.currentTarget);
314
      const cropVariant: CropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
315
      const imageData: Cropper.ImageData = this.cropper.getImageData();
316
317
318
319
320
321
322
323
      cropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
      this.currentCropVariant = $.extend(true, {}, cropVariant);
      this.update(cropVariant);
    });

    /**
     * Assign EventListener to aspectRatioTrigger
     */
324
    this.aspectRatioTrigger.off('click').on('click', (e: JQueryEventObject): void => {
325
      const ratioId: string = $(e.currentTarget).attr('data-bs-option');
326
327
      const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
      const ratio: Ratio = temp.allowedAspectRatios[ratioId];
328
      this.setAspectRatio(ratio);
329
      // set data explicitly or setAspectRatio upscales the crop
330
331
332
333
334
335
336
337
      this.setCropArea(temp.cropArea);
      this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
      this.update(this.currentCropVariant);
    });

    /**
     * Assign EventListener to saveButton
     */
338
    this.saveButton.off('click').on('click', (): void => {
339
340
341
342
343
344
345
      this.save(this.data);
    });

    /**
     * Assign EventListener to previewButton if preview url exists
     */
    if (this.trigger.attr('data-preview-url')) {
346
      this.previewButton.off('click').on('click', (): void => {
347
348
349
350
351
352
353
354
355
        this.openPreview(this.data);
      });
    } else {
      this.previewButton.hide();
    }

    /**
     * Assign EventListener to dismissButton
     */
356
357
    this.dismissButton.off('click').on('click', (): void => {
      this.currentModal.modal('hide');
358
359
360
361
362
    });

    /**
     * Assign EventListener to resetButton
     */
363
    this.resetButton.off('click').on('click', (e: JQueryEventObject): void => {
364
      const imageData: Cropper.ImageData = this.cropper.getImageData();
365
366
367
368
369
370
371
372
373
374
375
376
      const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
      e.preventDefault();
      e.stopPropagation();
      if (!resetCropVariantString) {
        throw new TypeError('TYPO3 Cropper: No cropVariant data attribute found on reset element.');
      }
      const resetCropVariant: CropVariant = JSON.parse(resetCropVariantString);
      const absoluteCropArea: Area = this.convertRelativeToAbsoluteCropArea(resetCropVariant.cropArea, imageData);
      this.currentCropVariant = $.extend(true, {}, resetCropVariant, {cropArea: absoluteCropArea});
      this.update(this.currentCropVariant);
    });

377
    // if we start without an cropArea, maximize the cropper
378
379
380
    if (ImageManipulation.isEmptyArea(this.currentCropVariant.cropArea)) {
      this.defaultOpts = $.extend({
        autoCropArea: 1,
381
      }, this.defaultOpts);
382
383
384
385
386
    }

    /**
     * Initialise the cropper
     */
387
388
    this.cropper = new Cropper((image.get(0) as HTMLImageElement), $.extend(this.defaultOpts, {
      ready: this.cropBuiltHandler,
389
390
391
392
393
394
395
      crop: this.cropMoveHandler,
      cropend: this.cropEndHandler,
      cropstart: this.cropStartHandler,
      data: this.currentCropVariant.cropArea,
    }));
  }

396
397
398
399
400
  /**
   * @method cropBuiltHandler
   * @desc Internal cropper handler. Called when the cropper has been instantiated
   * @private
   */
401
  private cropBuiltHandler = (): void => {
402
403
404
    this.initialized = true;

    const imageData: Cropper.ImageData = this.cropper.getImageData();
405
406
    const image: JQuery = this.currentModal.find(this.cropImageSelector);

407
408
409
410
    // Make the image in the backdrop visible again.
    // TODO: Check why this doesn't happen automatically.
    this.currentModal.find('.cropper-canvas img').removeClass('cropper-hide');

411
    this.imageOriginalSizeFactor = image.data('originalWidth') / imageData.naturalWidth;
412

413
    // iterate over the crop variants and set up their respective preview
414
415
416
417
    this.cropVariantTriggers.each((index: number, elem: Element): void => {
      const cropVariantId: string = $(elem).attr('data-crop-variant-id');
      const cropArea: Area = this.convertRelativeToAbsoluteCropArea(
        this.data[cropVariantId].cropArea,
418
        imageData,
419
420
421
422
423
      );
      const variant: CropVariant = $.extend(true, {}, this.data[cropVariantId], {cropArea});
      this.updatePreviewThumbnail(variant, $(elem));
    });

424
425
    this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
      this.currentCropVariant.cropArea,
426
      imageData,
427
    );
428
    // can't use .t3js-* as selector because it is an extraneous selector
429
430
431
432
    this.cropBox = this.currentModal.find('.cropper-crop-box');

    this.setCropArea(this.currentCropVariant.cropArea);

433
    // check if new cropVariant has coverAreas
434
    if (this.currentCropVariant.coverAreas) {
435
      // init or reinit focusArea
436
437
      this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
    }
438
    // check if new cropVariant has focusArea
439
    if (this.currentCropVariant.focusArea) {
440
      // init or reinit focusArea
441
      if (ImageManipulation.isEmptyArea(this.currentCropVariant.focusArea)) {
442
        // if an empty focusArea is set initialise it with the default
443
444
445
446
447
448
449
        this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
      }
      this.initFocusArea(this.cropBox);
      this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
    }

    if (this.currentCropVariant.selectedRatio) {
450
      // set data explicitly or setAspectRatio up-scales the crop
451
      this.currentModal.find(`[data-bs-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
452
    }
453
  }
454

455
456
457
458
459
  /**
   * @method cropMoveHandler
   * @desc Internal cropper handler. Called when the cropping area is moving
   * @private
   */
460
  private cropMoveHandler = (e: CropperEvent): void => {
461
462
463
464
    if (!this.initialized) {
      return;
    }

465
    this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
466
467
468
469
      height: Math.floor(e.detail.height),
      width: Math.floor(e.detail.width),
      x: Math.floor(e.detail.x),
      y: Math.floor(e.detail.y),
470
    });
471
    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
472
    this.updateCropVariantData(this.currentCropVariant);
473
474
475
    const naturalWidth: number = Math.round(this.currentCropVariant.cropArea.width * this.imageOriginalSizeFactor);
    const naturalHeight: number = Math.round(this.currentCropVariant.cropArea.height * this.imageOriginalSizeFactor);
    this.cropInfo.text(`${naturalWidth}×${naturalHeight} px`);
476
  }
477

478
479
480
481
482
  /**
   * @method cropStartHandler
   * @desc Internal cropper handler. Called when the cropping starts moving
   * @private
   */
483
484
485
486
487
  private cropStartHandler = (): void => {
    if (this.currentCropVariant.focusArea) {
      this.focusArea.draggable('option', 'disabled', true);
      this.focusArea.resizable('option', 'disabled', true);
    }
488
  }
489
490

  /**
491
492
493
   * @method cropEndHandler
   * @desc Internal cropper handler. Called when the cropping ends moving
   * @private
494
495
496
497
498
499
   */
  private cropEndHandler = (): void => {
    if (this.currentCropVariant.focusArea) {
      this.focusArea.draggable('option', 'disabled', false);
      this.focusArea.resizable('option', 'disabled', false);
    }
500
  }
501
502
503
504
505
506
507
508
509

  /**
   * @method update
   * @desc Update current cropArea position and size when changing cropVariants
   * @param {CropVariant} cropVariant - The new cropVariant to update the UI with
   */
  private update(cropVariant: CropVariant): void {
    const temp: CropVariant = $.extend(true, {}, cropVariant);
    const selectedRatio: Ratio = cropVariant.allowedAspectRatios[cropVariant.selectedRatio];
510
511
    this.currentModal.find('[data-bs-option]').removeClass('active');
    this.currentModal.find(`[data-bs-option="${cropVariant.selectedRatio}"]`).addClass('active');
512
513
514
    /**
     * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
     */
515
    this.setAspectRatio(selectedRatio);
516
517
518
519
    this.setCropArea(temp.cropArea);
    this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
    this.cropBox.find(this.coverAreaSelector).remove();

520
    // if the current container has a focus area element, deregister and cleanup prior to initialization
521
522
523
524
525
    if (this.cropBox.has(this.focusAreaSelector).length) {
      this.focusArea.resizable('destroy').draggable('destroy');
      this.focusArea.remove();
    }

526
    // check if new cropVariant has focusArea
527
    if (cropVariant.focusArea) {
528
      // init or reinit focusArea
529
530
531
532
533
534
535
      if (ImageManipulation.isEmptyArea(cropVariant.focusArea)) {
        this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
      }
      this.initFocusArea(this.cropBox);
      this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
    }

536
    // check if new cropVariant has coverAreas
537
    if (cropVariant.coverAreas) {
538
      // init or reinit focusArea
539
540
      this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
    }
541
    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
542
543
544
545
546
  }

  /**
   * @method initFocusArea
   * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
547
548
   * @param {JQuery} container
   * @private
549
550
551
552
553
   */
  private initFocusArea(container: JQuery): void {
    this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
    container.append(this.focusArea);
    this.focusArea
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
      .draggable({
        containment: container,
        create: (): void => {
          this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
        },
        drag: (): void => {
          const {left, top}: Offset = container.offset();
          const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
          const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;

          focusArea.x = (fLeft - left) / container.width();
          focusArea.y = (fTop - top) / container.height();
          this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
          if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
            this.focusArea.addClass('has-nodrop');
          } else {
            this.focusArea.removeClass('has-nodrop');
          }
        },
        revert: (): boolean => {
          const revertDelay = 250;
          const {left, top}: Offset = container.offset();
          const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
          const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;

          if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
            this.focusArea.removeClass('has-nodrop');
            ImageManipulation.wait((): void => {
              focusArea.x = (fLeft - left) / container.width();
              focusArea.y = (fTop - top) / container.height();
              this.updateCropVariantData(this.currentCropVariant);
585
            }, revertDelay);
586
587
588
589
590
591
592
593
594
            return true;
          }
          return false;
        },
        revertDuration: 200,
        stop: (): void => {
          const {left, top}: Offset = container.offset();
          const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
          const {focusArea}: {focusArea?: Area} = this.currentCropVariant;
595

596
597
          focusArea.x = (fLeft - left) / container.width();
          focusArea.y = (fTop - top) / container.height();
598

599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
          this.scaleAndMoveFocusArea(focusArea);
        },
      })
      .resizable({
        containment: container,
        handles: 'all',
        resize: (): void => {
          const {left, top}: Offset = container.offset();
          const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
          const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;

          focusArea.height = this.focusArea.height() / container.height();
          focusArea.width = this.focusArea.width() / container.width();
          focusArea.x = (fLeft - left) / container.width();
          focusArea.y = (fTop - top) / container.height();
          this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);

          if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
            this.focusArea.addClass('has-nodrop');
          } else {
            this.focusArea.removeClass('has-nodrop');
          }
621

622
623
624
625
626
627
628
629
630
631
632
633
634
635
        },
        stop: (event: any, ui: any): void => {
          const revertDelay = 250;
          const {left, top}: Offset = container.offset();
          const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
          const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;

          if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
            ui.element.animate($.extend(ui.originalPosition, ui.originalSize), revertDelay, (): void => {

              focusArea.height = this.focusArea.height() / container.height();
              focusArea.width = this.focusArea.width() / container.width();
              focusArea.x = (fLeft - left) / container.width();
              focusArea.y = (fTop - top) / container.height();
636
637

              this.scaleAndMoveFocusArea(focusArea);
638
639
640
641
642
643
644
              this.focusArea.removeClass('has-nodrop');
            });
          } else {
            this.scaleAndMoveFocusArea(focusArea);
          }
        },
      });
645
646
647
648
649
650
  }

  /**
   * @method initCoverAreas
   * @desc Initialise cover areas inside the cropper container
   * @param {JQuery} container - The container element to append the cover areas
651
   * @param {Array<Area>} coverAreas - An array of areas to construct the cover area elements from
652
653
654
   */
  private initCoverAreas(container: JQuery, coverAreas: Area[]): void {
    coverAreas.forEach((coverArea: Area): void => {
655
      const coverAreaCanvas: JQuery = $('<div class="cropper-cover-area t3js-cropper-cover-area"></div>');
656
657
658
659
660
661
662
663
664
665
666
667
      container.append(coverAreaCanvas);
      coverAreaCanvas.css({
        height: ImageManipulation.toCssPercent(coverArea.height),
        left: ImageManipulation.toCssPercent(coverArea.x),
        top: ImageManipulation.toCssPercent(coverArea.y),
        width: ImageManipulation.toCssPercent(coverArea.width),
      });
    });
  }

  /**
   * @method updatePreviewThumbnail
668
   * @desc Sync the cropping (and focus area) to the preview thumbnail
669
670
671
   * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
   * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
   * @private
672
   */
673
  private updatePreviewThumbnail(cropVariant: CropVariant, cropVariantTrigger: JQuery): void {
674
675
    let styles: any;
    const cropperPreviewThumbnailCrop: JQuery =
676
      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
677
    const cropperPreviewThumbnailImage: JQuery =
678
      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
679
    const cropperPreviewThumbnailFocus: JQuery =
680
      cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
681
    const imageData: Cropper.ImageData = this.cropper.getImageData();
682

683
    // update the position/dimension of the crop area in the preview
684
685
686
687
688
689
690
    cropperPreviewThumbnailCrop.css({
      height: ImageManipulation.toCssPercent(cropVariant.cropArea.height / imageData.naturalHeight),
      left: ImageManipulation.toCssPercent(cropVariant.cropArea.x / imageData.naturalWidth),
      top: ImageManipulation.toCssPercent(cropVariant.cropArea.y / imageData.naturalHeight),
      width: ImageManipulation.toCssPercent(cropVariant.cropArea.width / imageData.naturalWidth),
    });

691
    // show and update focusArea in the preview only if we really have one configured
692
693
694
695
696
697
698
699
700
    if (cropVariant.focusArea) {
      cropperPreviewThumbnailFocus.css({
        height: ImageManipulation.toCssPercent(cropVariant.focusArea.height),
        left: ImageManipulation.toCssPercent(cropVariant.focusArea.x),
        top: ImageManipulation.toCssPercent(cropVariant.focusArea.y),
        width: ImageManipulation.toCssPercent(cropVariant.focusArea.width),
      });
    }

701
    // destruct the preview container's CSS properties
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
    styles = cropperPreviewThumbnailCrop.css([
      'width', 'height', 'left', 'top',
    ]);

    /**
     * Apply negative margins on the previewThumbnailImage to make the illusion of an offset
     */
    cropperPreviewThumbnailImage.css({
      height: `${parseFloat(styles.height) * (1 / (cropVariant.cropArea.height / imageData.naturalHeight))}px`,
      margin: `${-1 * parseFloat(styles.left)}px`,
      marginTop: `${-1 * parseFloat(styles.top)}px`,
      width: `${parseFloat(styles.width) * (1 / (cropVariant.cropArea.width / imageData.naturalWidth))}px`,
    });
  }

  /**
   * @method scaleAndMoveFocusArea
   * @desc Calculation logic for moving the focus area given the
   *       specified constrains of a crop and an optional cover area
   * @param {Area} focusArea - The translation data
   */
  private scaleAndMoveFocusArea(focusArea: Area): void {
    this.focusArea.css({
      height: ImageManipulation.toCssPercent(focusArea.height),
      left: ImageManipulation.toCssPercent(focusArea.x),
      top: ImageManipulation.toCssPercent(focusArea.y),
      width: ImageManipulation.toCssPercent(focusArea.width),
    });
    this.currentCropVariant.focusArea = focusArea;
731
    this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
732
733
734
735
736
737
738
    this.updateCropVariantData(this.currentCropVariant);
  }

  /**
   * @method updateCropVariantData
   * @desc Immutably updates the currently selected cropVariant data
   * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
739
   * @private
740
741
   */
  private updateCropVariantData(currentCropVariant: CropVariant): void {
742
    const imageData: Cropper.ImageData = this.cropper.getImageData();
743
744
745
746
747
    const absoluteCropArea: Area = this.convertAbsoluteToRelativeCropArea(currentCropVariant.cropArea, imageData);
    this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, {cropArea: absoluteCropArea});
  }

  /**
748
749
750
751
   * @method setAspectRatio
   * @desc Sets the cropper to a specific ratio
   * @param {ratio} ratio - The ratio value to apply
   * @private
752
   */
753
  private setAspectRatio(ratio: Ratio): void {
754
    this.cropper.setAspectRatio(ratio.value);
755
756
757
758
  }

  /**
   * @method setCropArea
759
760
761
   * @desc Sets the cropper to a specific crop area
   * @param {cropArea} cropArea - The crop area to apply
   * @private
762
763
   */
  private setCropArea(cropArea: Area): void {
764
765
    const currentRatio: Ratio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
    if (currentRatio.value === 0) {
766
      this.cropper.setData({
767
768
769
770
771
772
        height: cropArea.height,
        width: cropArea.width,
        x: cropArea.x,
        y: cropArea.y,
      });
    } else {
773
      this.cropper.setData({
774
        height: cropArea.height,
775
        width: cropArea.height * currentRatio.value,
776
777
778
779
        x: cropArea.x,
        y: cropArea.y,
      });
    }
780
781
782
783
784
785
786
787
788
789
  }

  /**
   * @method checkFocusAndCoverAreas
   * @desc Checks is one focus area and one or more cover areas overlap
   * @param focusArea
   * @param coverAreas
   * @return {boolean}
   */
  private checkFocusAndCoverAreasCollision(focusArea: Area, coverAreas: Area[]): boolean {
790
791
792
    if (!coverAreas) {
      return false;
    }
793
794
795
    return coverAreas
      .some((coverArea: Area): boolean => {
        // noinspection OverlyComplexBooleanExpressionJS
796
797
798
799
        return (focusArea.x < coverArea.x + coverArea.width &&
           focusArea.x + focusArea.width > coverArea.x &&
            focusArea.y < coverArea.y + coverArea.height &&
           focusArea.height + focusArea.y > coverArea.y);
800
801
802
803
      });
  }

  /**
804
805
806
   * @method convertAbsoluteToRelativeCropArea
   * @desc Converts a crop area from absolute pixel-based into relative length values
   * @param {Area} cropArea - The crop area to convert from
807
   * @param {Cropper.ImageData} imageData - The image data
808
   * @return {Area}
809
   */
810
  private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: Cropper.ImageData): Area {
811
812
813
814
815
816
817
818
819
820
    const {height, width, x, y}: Area = cropArea;
    return {
      height: height / imageData.naturalHeight,
      width: width / imageData.naturalWidth,
      x: x / imageData.naturalWidth,
      y: y / imageData.naturalHeight,
    };
  }

  /**
821
822
823
   * @method convertRelativeToAbsoluteCropArea
   * @desc Converts a crop area from relative into absolute pixel-based length values
   * @param {Area} cropArea - The crop area to convert from
824
   * @param {Cropper.ImageData} imageData - The image data
825
826
   * @return {{height: number, width: number, x: number, y: number}}
   */
827
  private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: Cropper.ImageData): Area {
828
829
830
831
832
833
834
835
836
    const {height, width, x, y}: Area = cropArea;
    return {
      height: height * imageData.naturalHeight,
      width: width * imageData.naturalWidth,
      x: x * imageData.naturalWidth,
      y: y * imageData.naturalHeight,
    };
  }

837
838
839
840
841
  /**
   * @method setPreviewImages
   * @desc Updates the preview images in the editing section with the respective crop variants
   * @param {Object} data - The internal crop variants state
   */
842
  private setPreviewImages(data: {[key: string]: CropVariant}): void {
843
844
845
    // @ts-ignore .image is not declared
    const image: HTMLImageElement = this.cropper.image;
    const imageData: Cropper.ImageData = this.cropper.getImageData();
846

847
    // iterate over the crop variants and set up their respective preview
848
849
850
851
    Object.keys(data).forEach((cropVariantId: string) => {
      const cropVariant: CropVariant = data[cropVariantId];
      const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);

852
      const $preview: JQuery = this.trigger
853
854
        .closest('.form-group')
        .find(`.t3js-image-manipulation-preview[data-crop-variant-id="${cropVariantId}"]`);
855
      const $previewSelectedRatio: JQuery = this.trigger
856
857
        .closest('.form-group')
        .find(`.t3js-image-manipulation-selected-ratio[data-crop-variant-id="${cropVariantId}"]`); // tslint:disable-line:max-line-length
858
859
860
861
862

      if ($preview.length === 0) {
        return;
      }

863
      let previewWidth: number = $preview.width();
864
865
      let previewHeight: number = $preview.data('preview-height');

866
      // adjust aspect ratio of preview width/height
867
868
      const aspectRatio: number = cropData.width / cropData.height;
      const tmpHeight: number = previewWidth / aspectRatio;
869
870
871
872
873
874
875
876
877
878
879
      if (tmpHeight > previewHeight) {
        previewWidth = previewHeight * aspectRatio;
      } else {
        previewHeight = tmpHeight;
      }
      // preview should never be up-scaled
      if (previewWidth > cropData.width) {
        previewWidth = cropData.width;
        previewHeight = cropData.height;
      }

880
      const ratio: number = previewWidth / cropData.width;
881
      const $viewBox: JQuery = $('<div />').html('<img src="' + image.src + '">');
882
      const $ratioTitleText: JQuery = this.currentModal.find(`.t3-js-ratio-title[data-ratio-id="${cropVariant.id}${cropVariant.selectedRatio}"]`); // tslint:disable-line:max-line-length
883
      $previewSelectedRatio.text($ratioTitleText.text());
884
885
886
887
888
889
890
891
892
893
894
      $viewBox.addClass('cropper-preview-container');
      $preview.empty().append($viewBox);
      $viewBox.wrap('<span class="thumbnail thumbnail-status"></span>');

      $viewBox.width(previewWidth).height(previewHeight).find('img').css({
        height: imageData.naturalHeight * ratio,
        left: -cropData.x * ratio,
        top: -cropData.y * ratio,
        width: imageData.naturalWidth * ratio,
      });
    });
895
  }
896
897
898

  /**
   * @method openPreview
899
   * @desc Opens a preview view with the crop variants
900
901
902
   * @param {object} data - The whole data object containing all the cropVariants
   * @private
   */
903
  private openPreview(data: object): void {
904
905
906
907
908
909
910
911
912
913
914
915
    const cropVariants: string = ImageManipulation.serializeCropVariants(data);
    let previewUrl: string = this.trigger.attr('data-preview-url');
    previewUrl = previewUrl + '&cropVariants=' + encodeURIComponent(cropVariants);
    window.open(previewUrl, 'TYPO3ImageManipulationPreview');
  }

  /**
   * @method save
   * @desc Saves the edited cropVariants to a hidden field
   * @param {object} data - The whole data object containing all the cropVariants
   * @private
   */
916
  private save(data: {[key: string]: CropVariant}): void {
917
918
919
    const cropVariants: string = ImageManipulation.serializeCropVariants(data);
    const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
    this.trigger.attr('data-crop-variants', JSON.stringify(data));
920
    this.setPreviewImages(data);
921
    hiddenField.val(cropVariants);
922
    FormEngineValidation.markFieldAsChanged(hiddenField);
923
    this.currentModal.modal('hide');
924
925
926
927
928
929
930
931
932
  }

  /**
   * @method destroy
   * @desc Destroy the ImageManipulation including cropper and alike
   * @private
   */
  private destroy(): void {
    if (this.currentModal) {
933
934
      if (this.cropper instanceof Cropper) {
        this.cropper.destroy();
935
      }
936
      this.initialized = false;
937
      this.cropper = null;
938
      this.currentModal = null;
939
      this.data = null;
940
941
942
943
944
    }
  }
}

export = new ImageManipulation();