2 * This file is part of the TYPO3 CMS project.
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.
8 * For the full copyright and license information, please read the
9 * LICENSE.txt file that was distributed with this source code.
11 * The TYPO3 project - inspiring people to share!
14 /// <amd-dependency path='TYPO3/CMS/Core/Contrib/imagesloaded.pkgd.min' name='ImagesLoaded'>
15 /// <amd-dependency path='TYPO3/CMS/Backend/Modal' name='Modal'>
16 /// <amd-dependency path='TYPO3/CMS/Backend/Severity' name='Severity'>
18 import $ = require('jquery');
19 import 'jquery-ui/draggable';
20 import 'jquery-ui/resizable';
21 declare const Modal: any;
22 declare const Severity: any;
23 declare const ImagesLoaded: any;
47 selectedRatio: string;
51 allowedAspectRatios: Ratio[];
59 interface CropperEvent {
69 interface CropperImageData {
75 naturalHeight: number;
83 * Module: TYPO3/CMS/Backend/ImageManipulation
84 * Contains all logic for the image crop GUI including setting focusAreas
85 * @exports TYPO3/CMS/Backend/ImageManipulation
87 class ImageManipulation {
89 * @method isCropAreaEmpty
90 * @desc Checks if an area is set or pristine
91 * @param {Area} area - The area to check
95 public static isEmptyArea(area: Area): boolean {
96 return $.isEmptyObject(area);
101 * @desc window.setTimeout shim
102 * @param {Function} fn - The function to execute
103 * @param {number} ms - The time in [ms] to wait until execution
108 public static wait(fn: Function, ms: number): void {
109 window.setTimeout(fn, ms);
113 * @method toCssPercent
114 * @desc Takes a number, and converts it to CSS percentage length
115 * @param {number} num - The number to convert
120 public static toCssPercent(num: number): string {
121 return `${num * 100}%`;
125 * @method serializeCropVariants
126 * @desc Serializes crop variants for persistence or preview
127 * @param {Object} cropVariants
130 private static serializeCropVariants(cropVariants: Object): string {
131 const omitUnused: any = (key: any, value: any): any =>
135 || key === 'allowedAspectRatios'
136 || key === 'coverAreas'
137 ) ? undefined : value;
139 return JSON.stringify(cropVariants, omitUnused);
142 private trigger: JQuery;
143 private currentModal: JQuery;
144 private cropVariantTriggers: JQuery;
145 private activeCropVariantTrigger: JQuery;
146 private saveButton: JQuery;
147 private previewButton: JQuery;
148 private dismissButton: JQuery;
149 private resetButton: JQuery;
150 private aspectRatioTrigger: JQuery;
151 private cropperCanvas: JQuery;
152 private cropInfo: JQuery;
153 private cropImageContainerSelector: string = '#t3js-crop-image-container';
154 private cropImageSelector: string = '#t3js-crop-image';
155 private coverAreaSelector: string = '.t3js-cropper-cover-area';
156 private cropInfoSelector: string = '.t3js-cropper-info-crop';
157 private focusAreaSelector: string = '#t3js-cropper-focus-area';
158 private focusArea: any;
159 private cropBox: JQuery;
160 private cropper: any;
161 private currentCropVariant: CropVariant;
162 private data: Object;
163 private defaultFocusArea: Area = {
169 private defaultOpts: Object = {
178 private resizeTimeout: number = 450;
182 $(window).resize((): void => {
184 this.cropper.cropper('destroy');
187 this.resizeEnd((): void => {
195 * @method initializeTrigger
196 * @desc Assign a handler to .t3js-image-manipulation-trigger.
197 * Show the modal and kick-off image manipulation
200 public initializeTrigger(): void {
201 const triggerHandler: Function = (e: JQueryEventObject): void => {
203 this.trigger = $(e.currentTarget);
206 $('.t3js-image-manipulation-trigger').off('click').click(triggerHandler);
210 * @method initializeCropperModal
211 * @desc Initialize the cropper modal and dispatch the cropper init
214 private initializeCropperModal(): void {
215 const image: JQuery = this.currentModal.find(this.cropImageSelector);
216 ImagesLoaded(image, (): void => {
217 const modal: JQuery = this.currentModal.find('.modal-dialog');
218 modal.css({marginLeft: 'auto', marginRight: 'auto'});
219 modal.addClass('modal-image-manipulation modal-resize');
221 setTimeout((): void => {
229 * @desc Load the image and setup the modal UI
232 private show(): void {
233 const modalTitle: string = this.trigger.data('modalTitle');
234 const imageUri: string = this.trigger.data('url');
235 const initCropperModal: Function = this.initializeCropperModal.bind(this);
238 * Open modal with image to crop
240 this.currentModal = Modal.loadUrl(
248 this.currentModal.addClass('modal-dark');
249 this.currentModal.on('hide.bs.modal', (e: JQueryEventObject): void => {
252 // Do not dismiss the modal when clicking beside it to avoid data loss
253 this.currentModal.data('bs.modal').options.backdrop = 'static';
258 * @desc Initializes the cropper UI and sets up all the event indings for the UI
261 private init(): void {
262 const image: JQuery = this.currentModal.find(this.cropImageSelector);
263 const imageHeight: number = $(image).height();
264 const imageWidth: number = $(image).width();
265 const data: string = this.trigger.attr('data-crop-variants');
268 throw new TypeError('ImageManipulation: No cropVariants data found for image');
271 // If we have data already set we assume an internal reinit eg. after resizing
272 this.data = $.isEmptyObject(this.data) ? JSON.parse(data) : this.data;
273 // Initialize our class members
274 this.currentModal.find(this.cropImageContainerSelector).css({height: imageHeight, width: imageWidth});
275 this.cropVariantTriggers = this.currentModal.find('.t3js-crop-variant-trigger');
276 this.activeCropVariantTrigger = this.currentModal.find('.t3js-crop-variant-trigger.is-active');
277 this.cropInfo = this.currentModal.find(this.cropInfoSelector);
278 this.saveButton = this.currentModal.find('[data-method=save]');
279 this.previewButton = this.currentModal.find('[data-method=preview]');
280 this.dismissButton = this.currentModal.find('[data-method=dismiss]');
281 this.resetButton = this.currentModal.find('[data-method=reset]');
282 this.cropperCanvas = this.currentModal.find('#js-crop-canvas');
283 this.aspectRatioTrigger = this.currentModal.find('[data-method=setAspectRatio]');
284 this.currentCropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
287 * Assign EventListener to cropVariantTriggers
289 this.cropVariantTriggers.off('click').on('click', (e: JQueryEventObject): void => {
292 * Is the current cropVariantTrigger is active, bail out.
293 * Bootstrap doesn't provide this functionality when collapsing the Collaps panels
295 if ($(e.currentTarget).hasClass('is-active')) {
301 this.activeCropVariantTrigger.removeClass('is-active');
302 $(e.currentTarget).addClass('is-active');
303 this.activeCropVariantTrigger = $(e.currentTarget);
304 let cropVariant: CropVariant = this.data[this.activeCropVariantTrigger.attr('data-crop-variant-id')];
305 const imageData: CropperImageData = this.cropper.cropper('getImageData');
306 cropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
307 this.currentCropVariant = $.extend(true, {}, cropVariant);
308 this.update(cropVariant);
312 * Assign EventListener to aspectRatioTrigger
314 this.aspectRatioTrigger.off('click').on('click', (e: JQueryEventObject): void => {
315 const ratioId: string = $(e.currentTarget).attr('data-option');
316 const temp: CropVariant = $.extend(true, {}, this.currentCropVariant);
317 const ratio: Ratio = temp.allowedAspectRatios[ratioId];
318 this.setAspectRatio(ratio);
319 // Set data explicitly or setAspectRatio upscales the crop
320 this.setCropArea(temp.cropArea);
321 this.currentCropVariant = $.extend(true, {}, temp, {selectedRatio: ratioId});
322 this.update(this.currentCropVariant);
326 * Assign EventListener to saveButton
328 this.saveButton.off('click').on('click', (): void => {
329 this.save(this.data);
333 * Assign EventListener to previewButton if preview url exists
335 if (this.trigger.attr('data-preview-url')) {
336 this.previewButton.off('click').on('click', (): void => {
337 this.openPreview(this.data);
340 this.previewButton.hide();
344 * Assign EventListener to dismissButton
346 this.dismissButton.off('click').on('click', (): void => {
347 this.currentModal.modal('hide');
351 * Assign EventListener to resetButton
353 this.resetButton.off('click').on('click', (e: JQueryEventObject): void => {
354 const imageData: CropperImageData = this.cropper.cropper('getImageData');
355 const resetCropVariantString: string = $(e.currentTarget).attr('data-crop-variant');
358 if (!resetCropVariantString) {
359 throw new TypeError('TYPO3 Cropper: No cropVariant data attribute found on reset element.');
361 const resetCropVariant: CropVariant = JSON.parse(resetCropVariantString);
362 const absoluteCropArea: Area = this.convertRelativeToAbsoluteCropArea(resetCropVariant.cropArea, imageData);
363 this.currentCropVariant = $.extend(true, {}, resetCropVariant, {cropArea: absoluteCropArea});
364 this.update(this.currentCropVariant);
367 // If we start without an cropArea, maximize the cropper
368 if (ImageManipulation.isEmptyArea(this.currentCropVariant.cropArea)) {
369 this.defaultOpts = $.extend({
371 }, this.defaultOpts);
375 * Initialise the cropper
377 * Note: We use the extraneous jQuery object here, as CropperJS won't work inside the <iframe>
378 * The top.require is now inlined @see ImageManipulationElemen.php:143
379 * TODO: Find a better solution for cross iframe communications
381 this.cropper = (<any> top.TYPO3.jQuery(image)).cropper($.extend(this.defaultOpts, {
382 built: this.cropBuiltHandler,
383 crop: this.cropMoveHandler,
384 cropend: this.cropEndHandler,
385 cropstart: this.cropStartHandler,
386 data: this.currentCropVariant.cropArea,
391 * @method cropBuiltHandler
392 * @desc Internal cropper handler. Called when the cropper has been instantiated
395 private cropBuiltHandler = (): void => {
396 const imageData: CropperImageData = this.cropper.cropper('getImageData');
398 // Iterate over the crop variants and set up their respective preview
399 this.cropVariantTriggers.each((index: number, elem: Element): void => {
400 const cropVariantId: string = $(elem).attr('data-crop-variant-id');
401 const cropArea: Area = this.convertRelativeToAbsoluteCropArea(
402 this.data[cropVariantId].cropArea,
405 const variant: CropVariant = $.extend(true, {}, this.data[cropVariantId], {cropArea});
406 this.updatePreviewThumbnail(variant, $(elem));
409 this.currentCropVariant.cropArea = this.convertRelativeToAbsoluteCropArea(
410 this.currentCropVariant.cropArea,
413 // Can't use .t3js-* as selector because it is an extraneous selector
414 this.cropBox = this.currentModal.find('.cropper-crop-box');
416 this.setCropArea(this.currentCropVariant.cropArea);
418 // Check if new cropVariant has coverAreas
419 if (this.currentCropVariant.coverAreas) {
420 // Init or reinit focusArea
421 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
423 // Check if new cropVariant has focusArea
424 if (this.currentCropVariant.focusArea) {
425 // Init or reinit focusArea
426 if (ImageManipulation.isEmptyArea(this.currentCropVariant.focusArea)) {
427 // If an empty focusArea is set initialise it with the default
428 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
430 this.initFocusArea(this.cropBox);
431 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
434 if (this.currentCropVariant.selectedRatio) {
435 this.setAspectRatio(this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio]);
436 // Set data explicitly or setAspectRatio up-scales the crop
437 this.setCropArea(this.currentCropVariant.cropArea);
438 this.currentModal.find(`[data-option='${this.currentCropVariant.selectedRatio}']`).addClass('active');
441 this.cropperCanvas.addClass('is-visible');
445 * @method cropMoveHandler
446 * @desc Internal cropper handler. Called when the cropping area is moving
449 private cropMoveHandler = (e: CropperEvent): void => {
450 this.currentCropVariant.cropArea = $.extend(true, this.currentCropVariant.cropArea, {
451 height: Math.floor(e.height),
452 width: Math.floor(e.width),
456 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
457 this.updateCropVariantData(this.currentCropVariant);
458 this.cropInfo.text(`${this.currentCropVariant.cropArea.width}×${this.currentCropVariant.cropArea.height} px`);
462 * @method cropStartHandler
463 * @desc Internal cropper handler. Called when the cropping starts moving
466 private cropStartHandler = (): void => {
467 if (this.currentCropVariant.focusArea) {
468 this.focusArea.draggable('option', 'disabled', true);
469 this.focusArea.resizable('option', 'disabled', true);
474 * @method cropEndHandler
475 * @desc Internal cropper handler. Called when the cropping ends moving
478 private cropEndHandler = (): void => {
479 if (this.currentCropVariant.focusArea) {
480 this.focusArea.draggable('option', 'disabled', false);
481 this.focusArea.resizable('option', 'disabled', false);
487 * @desc Update current cropArea position and size when changing cropVariants
488 * @param {CropVariant} cropVariant - The new cropVariant to update the UI with
490 private update(cropVariant: CropVariant): void {
491 const temp: CropVariant = $.extend(true, {}, cropVariant);
492 const selectedRatio: Ratio = cropVariant.allowedAspectRatios[cropVariant.selectedRatio];
493 this.currentModal.find('[data-option]').removeClass('active');
494 this.currentModal.find(`[data-option="${cropVariant.selectedRatio}"]`).addClass('active');
496 * Setting the aspect ratio cause a redraw of the crop area so we need to manually reset it to last data
498 this.setAspectRatio(selectedRatio);
499 this.setCropArea(temp.cropArea);
500 this.currentCropVariant = $.extend(true, {}, temp, cropVariant);
501 this.cropBox.find(this.coverAreaSelector).remove();
503 // If the current container has a focus area element, deregister and cleanup prior to initialization
504 if (this.cropBox.has(this.focusAreaSelector).length) {
505 this.focusArea.resizable('destroy').draggable('destroy');
506 this.focusArea.remove();
509 // Check if new cropVariant has focusArea
510 if (cropVariant.focusArea) {
511 // Init or reinit focusArea
512 if (ImageManipulation.isEmptyArea(cropVariant.focusArea)) {
513 this.currentCropVariant.focusArea = $.extend(true, {}, this.defaultFocusArea);
515 this.initFocusArea(this.cropBox);
516 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
519 // Check if new cropVariant has coverAreas
520 if (cropVariant.coverAreas) {
521 // Init or reinit focusArea
522 this.initCoverAreas(this.cropBox, this.currentCropVariant.coverAreas);
524 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
528 * @method initFocusArea
529 * @desc Initializes the focus area inside a container and registers the resizable and draggable interfaces to it
530 * @param {JQuery} container
533 private initFocusArea(container: JQuery): void {
534 this.focusArea = $('<div id="t3js-cropper-focus-area" class="cropper-focus-area"></div>');
535 container.append(this.focusArea);
538 containment: container,
539 create: (): void => {
540 this.scaleAndMoveFocusArea(this.currentCropVariant.focusArea);
543 const {left, top}: Offset = container.offset();
544 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
545 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
547 focusArea.x = (fLeft - left) / container.width();
548 focusArea.y = (fTop - top) / container.height();
549 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
550 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
551 this.focusArea.addClass('has-nodrop');
553 this.focusArea.removeClass('has-nodrop');
556 revert: (): boolean => {
557 const revertDelay: number = 250;
558 const {left, top}: Offset = container.offset();
559 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
560 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
562 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
563 this.focusArea.removeClass('has-nodrop');
564 ImageManipulation.wait((): void => {
565 focusArea.x = (fLeft - left) / container.width();
566 focusArea.y = (fTop - top) / container.height();
567 this.updateCropVariantData(this.currentCropVariant);
574 const {left, top}: Offset = container.offset();
575 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
576 const {focusArea}: {focusArea?: Area} = this.currentCropVariant;
578 focusArea.x = (fLeft - left) / container.width();
579 focusArea.y = (fTop - top) / container.height();
581 this.scaleAndMoveFocusArea(focusArea);
585 containment: container,
587 resize: (): void => {
588 const {left, top}: Offset = container.offset();
589 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
590 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
592 focusArea.height = this.focusArea.height() / container.height();
593 focusArea.width = this.focusArea.width() / container.width();
594 focusArea.x = (fLeft - left) / container.width();
595 focusArea.y = (fTop - top) / container.height();
596 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
598 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
599 this.focusArea.addClass('has-nodrop');
601 this.focusArea.removeClass('has-nodrop');
605 stop: (event: any, ui: any): void => {
606 const revertDelay: number = 250;
607 const {left, top}: Offset = container.offset();
608 const {left: fLeft, top: fTop}: Offset = this.focusArea.offset();
609 const {focusArea, coverAreas}: {focusArea?: Area, coverAreas?: Area[]} = this.currentCropVariant;
611 if (this.checkFocusAndCoverAreasCollision(focusArea, coverAreas)) {
612 ui.element.animate($.extend(ui.originalPosition, ui.originalSize), revertDelay, (): void => {
614 focusArea.height = this.focusArea.height() / container.height();
615 focusArea.height = this.focusArea.height() / container.height();
616 focusArea.width = this.focusArea.width() / container.width();
617 focusArea.x = (fLeft - left) / container.width();
618 focusArea.y = (fTop - top) / container.height();
620 this.scaleAndMoveFocusArea(focusArea);
621 this.focusArea.removeClass('has-nodrop');
624 this.scaleAndMoveFocusArea(focusArea);
631 * @method initCoverAreas
632 * @desc Initialise cover areas inside the cropper container
633 * @param {JQuery} container - The container element to append the cover areas
634 * @param {Array<Area>} coverAreas - An array of areas to construxt the cover area elements from
636 private initCoverAreas(container: JQuery, coverAreas: Area[]): void {
637 coverAreas.forEach((coverArea: Area): void => {
638 let coverAreaCanvas: JQuery = $('<div class="cropper-cover-area t3js-cropper-cover-area"></div>');
639 container.append(coverAreaCanvas);
640 coverAreaCanvas.css({
641 height: ImageManipulation.toCssPercent(coverArea.height),
642 left: ImageManipulation.toCssPercent(coverArea.x),
643 top: ImageManipulation.toCssPercent(coverArea.y),
644 width: ImageManipulation.toCssPercent(coverArea.width),
650 * @method updatePreviewThumbnail
651 * @desc Sync the croping (and focus area) to the preview thumbnail
652 * @param {CropVariant} cropVariant - The crop variant to preview in the thumbnail
653 * @param {JQuery} cropVariantTrigger - The crop variant element containing the thumbnail
656 private updatePreviewThumbnail(cropVariant: CropVariant, cropVariantTrigger: JQuery): void {
658 const cropperPreviewThumbnailCrop: JQuery =
659 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-area');
660 const cropperPreviewThumbnailImage: JQuery =
661 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-crop-image');
662 const cropperPreviewThumbnailFocus: JQuery =
663 cropVariantTrigger.find('.t3js-cropper-preview-thumbnail-focus-area');
664 const imageData: CropperImageData = this.cropper.cropper('getImageData');
666 // Update the position/dimension of the crop area in the preview
667 cropperPreviewThumbnailCrop.css({
668 height: ImageManipulation.toCssPercent(cropVariant.cropArea.height / imageData.naturalHeight),
669 left: ImageManipulation.toCssPercent(cropVariant.cropArea.x / imageData.naturalWidth),
670 top: ImageManipulation.toCssPercent(cropVariant.cropArea.y / imageData.naturalHeight),
671 width: ImageManipulation.toCssPercent(cropVariant.cropArea.width / imageData.naturalWidth),
674 // Show and update focusArea in the preview only if we really have one configured
675 if (cropVariant.focusArea) {
676 cropperPreviewThumbnailFocus.css({
677 height: ImageManipulation.toCssPercent(cropVariant.focusArea.height),
678 left: ImageManipulation.toCssPercent(cropVariant.focusArea.x),
679 top: ImageManipulation.toCssPercent(cropVariant.focusArea.y),
680 width: ImageManipulation.toCssPercent(cropVariant.focusArea.width),
684 // Destruct the preview container's CSS properties
685 styles = cropperPreviewThumbnailCrop.css([
686 'width', 'height', 'left', 'top',
690 * Apply negative margins on the previewThumbnailImage to make the illusion of an offset
692 cropperPreviewThumbnailImage.css({
693 height: `${parseFloat(styles.height) * (1 / (cropVariant.cropArea.height / imageData.naturalHeight))}px`,
694 margin: `${-1 * parseFloat(styles.left)}px`,
695 marginTop: `${-1 * parseFloat(styles.top)}px`,
696 width: `${parseFloat(styles.width) * (1 / (cropVariant.cropArea.width / imageData.naturalWidth))}px`,
701 * @method scaleAndMoveFocusArea
702 * @desc Calculation logic for moving the focus area given the
703 * specified constrains of a crop and an optional cover area
704 * @param {Area} focusArea - The translation data
706 private scaleAndMoveFocusArea(focusArea: Area): void {
708 height: ImageManipulation.toCssPercent(focusArea.height),
709 left: ImageManipulation.toCssPercent(focusArea.x),
710 top: ImageManipulation.toCssPercent(focusArea.y),
711 width: ImageManipulation.toCssPercent(focusArea.width),
713 this.currentCropVariant.focusArea = focusArea;
714 this.updatePreviewThumbnail(this.currentCropVariant, this.activeCropVariantTrigger);
715 this.updateCropVariantData(this.currentCropVariant);
719 * @method updateCropVariantData
720 * @desc Immutably updates the currently selected cropVariant data
721 * @param {CropVariant} currentCropVariant - The cropVariant to immutably save
724 private updateCropVariantData(currentCropVariant: CropVariant): void {
725 const imageData: CropperImageData = this.cropper.cropper('getImageData');
726 const absoluteCropArea: Area = this.convertAbsoluteToRelativeCropArea(currentCropVariant.cropArea, imageData);
727 this.data[currentCropVariant.id] = $.extend(true, {}, currentCropVariant, {cropArea: absoluteCropArea});
731 * @method setAspectRatio
732 * @desc Sets the cropper to a specific ratio
733 * @param {ratio} ratio - The ratio value to apply
736 private setAspectRatio(ratio: Ratio): void {
737 this.cropper.cropper('setAspectRatio', ratio.value);
741 * @method setCropArea
742 * @desc Sets the cropper to a specific crop area
743 * @param {cropArea} cropArea - The crop area to apply
746 private setCropArea(cropArea: Area): void {
747 const currentRatio: Ratio = this.currentCropVariant.allowedAspectRatios[this.currentCropVariant.selectedRatio];
748 if (currentRatio.value === 0) {
749 this.cropper.cropper('setData', {
750 height: cropArea.height,
751 width: cropArea.width,
756 this.cropper.cropper('setData', {
757 height: cropArea.height,
765 * @method checkFocusAndCoverAreas
766 * @desc Checks is one focus area and one or more cover areas overlap
771 private checkFocusAndCoverAreasCollision(focusArea: Area, coverAreas: Area[]): boolean {
773 .some((coverArea: Area): boolean => {
774 // noinspection OverlyComplexBooleanExpressionJS
775 if (focusArea.x < coverArea.x + coverArea.width &&
776 focusArea.x + focusArea.width > coverArea.x &&
777 focusArea.y < coverArea.y + coverArea.height &&
778 focusArea.height + focusArea.y > coverArea.y) {
785 * @method convertAbsoluteToRelativeCropArea
786 * @desc Converts a crop area from absolute pixel-based into relative length values
787 * @param {Area} cropArea - The crop area to convert from
788 * @param {CropperImageData} imageData - The image data
791 private convertAbsoluteToRelativeCropArea(cropArea: Area, imageData: CropperImageData): Area {
792 const {height, width, x, y}: Area = cropArea;
794 height: height / imageData.naturalHeight,
795 width: width / imageData.naturalWidth,
796 x: x / imageData.naturalWidth,
797 y: y / imageData.naturalHeight,
802 * @method convertRelativeToAbsoluteCropArea
803 * @desc Converts a crop area from relative into absolute pixel-based length values
804 * @param {Area} cropArea - The crop area to convert from
805 * @param {CropperImageData} imageData - The image data
806 * @return {{height: number, width: number, x: number, y: number}}
808 private convertRelativeToAbsoluteCropArea(cropArea: Area, imageData: CropperImageData): Area {
809 const {height, width, x, y}: Area = cropArea;
811 height: height * imageData.naturalHeight,
812 width: width * imageData.naturalWidth,
813 x: x * imageData.naturalWidth,
814 y: y * imageData.naturalHeight,
819 * @method setPreviewImages
820 * @desc Updates the preview images in the editing section with the respective crop variants
821 * @param {Object} data - The internal crop variants state
823 private setPreviewImages(data: Object): void {
824 let $image: any = this.cropper;
825 let imageData: CropperImageData = $image.cropper('getImageData');
827 // Iterate over the crop variants and set up their respective preview
828 Object.keys(data).forEach((cropVariantId: string) => {
829 const cropVariant: CropVariant = data[cropVariantId];
830 const cropData: Area = this.convertRelativeToAbsoluteCropArea(cropVariant.cropArea, imageData);
832 let $preview: JQuery = this.trigger
833 .closest('.form-group')
834 .find(`.t3js-image-manipulation-preview[data-crop-variant-id="${cropVariantId}"]`);
836 if ($preview.length === 0) {
840 let previewWidth: number = $preview.data('preview-width');
841 let previewHeight: number = $preview.data('preview-height');
843 // Adjust aspect ratio of preview width/height
844 let aspectRatio: number = cropData.width / cropData.height;
845 let tmpHeight: number = previewWidth / aspectRatio;
846 if (tmpHeight > previewHeight) {
847 previewWidth = previewHeight * aspectRatio;
849 previewHeight = tmpHeight;
851 // preview should never be up-scaled
852 if (previewWidth > cropData.width) {
853 previewWidth = cropData.width;
854 previewHeight = cropData.height;
857 let ratio: number = previewWidth / cropData.width;
859 let $viewBox: JQuery = $('<div />').html('<img src="' + $image.attr('src') + '">');
860 $viewBox.addClass('cropper-preview-container');
861 $preview.empty().append($viewBox);
862 $viewBox.wrap('<span class="thumbnail thumbnail-status"></span>');
864 $viewBox.width(previewWidth).height(previewHeight).find('img').css({
865 height: imageData.naturalHeight * ratio,
866 left: -cropData.x * ratio,
867 top: -cropData.y * ratio,
868 width: imageData.naturalWidth * ratio,
874 * @method openPreview
875 * @desc Opens a preview view with the crop variants
876 * @param {object} data - The whole data object containing all the cropVariants
879 private openPreview(data: Object): void {
880 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
881 let previewUrl: string = this.trigger.attr('data-preview-url');
882 previewUrl = previewUrl + '&cropVariants=' + encodeURIComponent(cropVariants);
883 window.open(previewUrl, 'TYPO3ImageManipulationPreview');
888 * @desc Saves the edited cropVariants to a hidden field
889 * @param {object} data - The whole data object containing all the cropVariants
892 private save(data: Object): void {
893 const cropVariants: string = ImageManipulation.serializeCropVariants(data);
894 const hiddenField: JQuery = $(`#${this.trigger.attr('data-field')}`);
895 this.trigger.attr('data-crop-variants', JSON.stringify(data));
896 this.setPreviewImages(data);
897 hiddenField.val(cropVariants);
898 this.currentModal.modal('hide');
903 * @desc Destroy the ImageManipulation including cropper and alike
906 private destroy(): void {
907 if (this.currentModal) {
908 this.cropper.cropper('destroy');
910 this.currentModal = null;
917 * @desc Calls a function when the cropper has been resized
918 * @param {Function} fn - The function to call on resize completion
921 private resizeEnd(fn: Function): void {
923 $(window).on('resize', (): void => {
925 timer = setTimeout((): void => {
927 }, this.resizeTimeout);
932 export = new ImageManipulation();